Skip to content

11. Hibernate

The previous chapter demonstrated how a Kotlin application interacts with a database through a JDBC driver: a connection is opened, SQL commands are executed, the result rows are read column by column, and they are copied 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. Kotlin does not have its own ORM of the same caliber: it directly uses those from the Java platform—Hibernate foremost among them—just as it uses ORMs. You only need to know a few rules to write Kotlin entities that work with Hibernate: this chapter introduces them as we go along.

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

11.1. What is a ORM?

11.1.1. Objects and Tables

A Kotlin 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.client]); a row contains the primary key value of another row ([client_id]);
  • an object can contain a collection ([order.lines]); a table contains only single values; the collection is represented by the rows of another table that point to the order;
  • the Kotlin types ([String], [BigDecimal], [LocalDate], [Boolean]) and the SQL types ([varchar(30)], [decimal(10,2)], [date], [bit]) are not the same;
  • objects are queried using Kotlin (the [filter], [map]... operations on collections) and 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 an object-oriented language (HQL) or constructed using code (API Criteria) into SQL and [SELECT] commands ;
  • 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;
  • to 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 the DBMS: model, query translation, change tracking, schema generation;
  • [3]: the dialect specific to the DBMS (here, [MySQLDialect]): it knows the SQL syntax of the 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 the DBMS, the one discussed in Chapter 10 (MySQL Connector/J): Hibernate does not replace JDBC; it uses it;
  • [5]: the DBMS.

Whether the application is written in Java or Kotlin does not change this pattern: Hibernate is a Java library that Kotlin code calls just like any other Java library (Chapter 5), and the compiled Kotlin classes are, to Hibernate, Java classes just like any others.

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 platform EE of that time. Its success was such that its ideas 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 (“native”) API, which is more feature-rich and 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 via code

In the Kotlin ecosystem, you’ll also encounter data access libraries of a different nature, such as Exposed (from JetBrains), which allows you to write SQL queries using a dedicated Kotlin language. These are lighter than a full ORM; Hibernate remains the standard for Spring applications, in both Java and Kotlin, and that is what we are studying.

11.1.3. Libraries

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

Gradle 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.

In addition to these libraries, there are two Kotlin compiler plugins that adapt Kotlin classes to Hibernate’s requirements: [kotlin("plugin.jpa")] and [kotlin("plugin.allopen")]. We’ll see why they’re necessary when we look at the first entity.

11.1.4. Code-First or Database-First

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

  • code-first: you write the Kotlin classes, and Hibernate infers the database schema from them, which it can then create on its own. This is the approach taken in this chapter;
  • database-first: the database already exists, and the classes are generated from its schema using Hibernate’s reverse-engineering tools (Hibernate Tools) or those provided by development environments (IntelliJ IDEA can generate entities from database tables in both Java and Kotlin). This is the approach used 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 subprojects of a multi-project Gradle build (see Chapter 6), located in the [11-hibernate] folder:

11-hibernate/
  settings.gradle.kts     the list of the subprojects
  build.gradle.kts        the root project: the plugins and their versions
  gradlew, gradlew.bat    the Gradle wrapper
  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, virtual threads and coroutines
  transactions/           transactions
  concurrency/            optimistic concurrency
  rawsql/                 native SQL
  bulkupdate/             bulk updates
  injection/              Hibernate and Spring
  TaxV7/                  the tax calculation application, version 7

The file [settings.gradle.kts] declares the subprojects:

// chapter 11: Hibernate

// Gradle downloads the JDK requested by jvmToolchain(25) itself if it does not find it on the machine
plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}

rootProject.name = "hibernate"

// two libraries common to the examples of the chapter
include("tools", "articles")
// the examples
include("first", "model", "crud", "hql", "criteria", "relations", "clients",
    "async", "transactions", "concurrency", "rawsql", "bulkupdate", "injection")
// version 7 of the [Tax] application: a [dao] layer written with Hibernate
include("TaxV7:tax-dao-hibernate", "TaxV7:tax-ui-hibernate")

// the [business] and [ui] layers of version 5 (chapter 6) are reused: composite build
includeBuild("../06-architectures")
  • lines 3–6: the plugin that allows Gradle to download JDK 25 itself if it cannot find it on the machine (Chapter 2);
  • line 8: the name of the root project;
  • line 11: the two libraries common to the examples;
  • lines 13–14: a subproject, for example. Gradle does not require them to be listed in the order of their dependencies; it deduces this itself from the dependencies declared between subprojects;
  • Line 16: The two subprojects for version 7 of the tax calculation application. The name [TaxV7:tax-dao-hibernate] refers to the folder [TaxV7/tax-dao-hibernate];
  • Line 19: the composite structure of Chapter 6, whose application TaxV7 will reuse the layers [business] and [ui].

The root project declares, along with their versions, the plugins used by the subprojects:

1
2
3
4
5
6
7
// chapter 11: root project. The plugins are declared here (with their version) and applied by the subprojects
plugins {
    kotlin("jvm") version "2.4.20" apply false
    kotlin("plugin.jpa") version "2.4.20" apply false
    kotlin("plugin.allopen") version "2.4.20" apply false
    kotlin("plugin.spring") version "2.4.20" apply false
}
  • line 3: the Kotlin plugin for JVM;
  • lines 4–5: the two Kotlin compiler plugins required by the entities (described later);
  • line 6: the “all-open” plugin configured for Spring (Chapter 6), used by the [injection] example;
  • [apply false]: The plugins are only declared here, along with their versions; each subproject applies the ones it needs, without repeating the version.

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

// a first example: an entity, a database, a few SQL statements generated by Hibernate
plugins {
    kotlin("jvm")
    // the parameterless constructor of the entities ("no-arg" plugin configured for JPA)
    kotlin("plugin.jpa")
    // entities and properties made open (non-final): Hibernate can derive proxies from them
    kotlin("plugin.allopen")
    application
}

repositories {
    mavenCentral()
}

dependencies {
    // Hibernate and the MySQL JDBC driver
    implementation("org.hibernate.orm:hibernate-core:7.4.5.Final")
    runtimeOnly("com.mysql:mysql-connector-j:9.7.0")
    // Logback: it receives the Hibernate log (only at runtime)
    runtimeOnly("ch.qos.logback:logback-classic:1.5.38")
}

// the classes annotated this way are opened by the [allopen] plugin
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the program run by [gradlew -q :first:run], launched in the 11-hibernate folder
    mainClass = "first.MainKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • lines 3–8: the applied plugins: Kotlin, the two entity plugins, and [application] to run the program;
  • lines 17–20: Hibernate, the JDBC driver for MySQL, and Logback. The driver and Logback are used only at runtime ([runtimeOnly]): the program code does not reference any of their classes;
  • lines 24–27: the configuration of the [allopen] plugin (described below);
  • lines 31–34: the standard compilation options for the course (JDK 25, warnings treated as errors);
  • line 40: the main class of the subproject. A [Main.kt] file from the [first] package is compiled into a [first.MainKt] class (Chapter 3);
  • lines 43–46: The programs launched by Gradle write their output to UTF-8 so that accented characters display correctly on Windows (Chapter 2).

To run an example, navigate to the [11-hibernate] folder and specify the subproject’s task [run] by its path ([:first:run]); Gradle first builds the subprojects it depends on (on Linux and macOS, type [./gradlew]):

> cd 11-hibernate
> gradlew -q :first:run

The program is executed in the subproject folder ([11-hibernate/first]). 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: 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 in the table [articles] from 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 Kotlin class, plus a few annotations
@Entity
class Article(
    // the columns of the table, declared as properties of the primary constructor
    var name: String,
    var price: BigDecimal,
    var currentStock: Int,
    var minimumStock: Int,
) {
    // the primary key, generated by the DBMS: null as long as the article is not saved
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null

    // display
    override fun toString() = "[%d, %s, %.2f, %d, %d]".format(id, name, price, currentStock, minimumStock)
}
  • Line 10: The annotation [@Entity] (package [jakarta.persistence]) makes the class an entity. This is a regular Kotlin class that does not extend any Hibernate class; it is referred to as a POJO (“Plain Old Java Object”);
  • lines 13–16: the properties declared in the primary constructor (Chapter 4). These are [var]: Hibernate must be able to modify their values, and so must the application (to change a price or inventory level). Each property becomes a column with the same name;
  • lines 19–21: 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 the DBMS (the [AUTO_INCREMENT] column of MySQL). The identifier is not a constructor parameter: it will be provided by the DBMS. Its type is [Int?], and its initial value is [null], which means “not yet recorded in the database.” The type [Int?] is compiled into [java.lang.Integer], and the type [Int] into [int] (Chapter 3);
  • line 24: a convenient display of the item. The format [%.2f] displays the price with two decimal places, in accordance with common cultural conventions (a comma in France, a point in the English locale used for the results).

Where do annotations go? In bytecode, a Kotlin property corresponds to several Java elements: a private field, an accessor ([getX]), a modifier ([setX]), and, in this case, a constructor parameter. An annotation placed before a property, without further specification, is positioned by Kotlin 2.4 based on the targets it accepts: on the constructor parameter if it is a constructor property and if the annotation allows it, and additionally on the Kotlin property itself if the annotation allows it; otherwise, on the field. Jakarta Persistence annotations ([@Id], [@Column]...) do not apply to Kotlin parameters or properties, but only to Java fields and methods: they are therefore placed on the field. This is exactly what we want: since the [@Id] annotation is on a field, Hibernate accesses the fields directly (it reads and writes them via reflection, even if they are private). You can verify this using the [javap] tool from JDK, which displays the contents of a [.class] file. You can also specify the target of an annotation using a prefix: [@field:Column(length = 30)] places it on the field, [@get:Transient] on the accessor.

Jakarta Persistence requires two things that Kotlin classes do not provide out of the box:

  • a parameterless constructor, which Hibernate uses to create objects read from the database (it then populates their fields). Our class has only one constructor, with four parameters. In Java, you manually add a protected parameterless constructor. In Kotlin, it is the compiler plugin [no-arg], configured for JPA ([kotlin("plugin.jpa")]), that adds it to any class annotated with [@Entity], [@Embeddable], or [@MappedSuperclass]. This constructor exists in the bytecode, where Hibernate finds it through reflection, but it is not visible from the Kotlin code: [Article()] remains a compilation error (“[no value passed for parameter 'name'.]”). Note that it does not execute property initializations: a property declared as [var x = "a"] is set to [null] in an object created by Hibernate, as long as Hibernate has not assigned a value to it (the [noArg { invokeInitializers = true }] option in the build file changes this behavior). This is not a problem for properties stored in the database, since Hibernate assigns them the value that was read;
  • non-final classes and methods. To implement lazy loading (which we’ll cover when discussing relationships), Hibernate creates proxies at runtime: classes derived from the entities, whose methods load the object from the database on the first call. For this to work, the class and its methods must be overridable. In Java, this is the default behavior; in Kotlin, classes and their members are final by default (Chapter 4). The [all-open] ([kotlin("plugin.allopen")]) plugin, configured by the [allOpen] block in the build file, makes classes bearing one of the listed annotations—as well as their properties and methods—non-final ([open]). The [plugin.spring] plugin from Chapter 6 is the same plugin, configured for Spring annotations.

With these two plugins, the entity is written as a regular Kotlin class. Without them, you would have to write [open class Article(...)], declare [open] for each property, and add a secondary constructor with no parameters: we’ll do this for a class without annotations in the following example.

Why not use a data class ([data class], Chapter 4)? It would seem ideal for representing a table row. However, this is a bad idea for an entity, for several reasons:

  • The generated methods [equals] and [hashCode] compare all properties of the primary constructor, which are modifiable. The hash code of an entity therefore changes as soon as its price is modified: an item stored in a [HashSet] or serving as the key of a [HashMap] becomes untraceable. Hibernate itself stores the elements of relationship collections ([Set]) using these methods;
  • two objects representing two different rows (with two different identifiers) but with the same values would be considered equal, whereas an item read from the database and then modified would no longer be equal to itself when read again;
  • the generated [toString] method displays all properties: with relationships, it iterates through the linked objects, which can cause them to be loaded from the database (unexpected SQL queries), an exception if the session is closed, or even an infinite recursion between two entities that reference each other;
  • The method [copy] creates a new object with the same identifier, which is unknown to the session: this results in two objects for the same row, which can lead to confusion;
  • A data class cannot be declared as open ([open]) by the programmer. The [all-open] plugin opens it anyway, which allows proxies to be created (we verified this using the [javap] tool), but it does not correct any of the previous issues.

An entity is therefore an ordinary class: its identity is that of the table row, not the value of its properties. As in Java, it retains the [equals]/[hashCode] inherited from [Any] (object identity), which is fine as long as you do not mix objects from different sessions. Data classes remain very useful with Hibernate for query results that are not entities (projections), as we will see. This is the same rule as in Java, where an entity cannot be a record.

One final note: Hibernate does not recognize Kotlin’s nullable types. The fact that the property [name] is of type [String] (non-nullable) does not tell Hibernate that the column is required; only the annotation [@Column(nullable = false)] tells it that, as we’ll see in the results.

11.2.3. [SessionFactory] and [Session]

The program uses three Hibernate objects:

  • the [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 used with the Kotlin function [use] (Chapter 3), which closes them at the end of the block, whether it terminates normally or with an exception (the equivalent of the [try] statement with Java resources).

11.2.4. The program

package first

import org.hibernate.jpa.HibernatePersistenceConfiguration
import java.math.BigDecimal

fun main() {
    // the SessionFactory: Hibernate configured for the [hbfirst] database
    HibernatePersistenceConfiguration("first")
        // the entities
        .managedClass(Article::class.java)
        // 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().use { sessionFactory ->

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

            // adding three articles, in a transaction
            sessionFactory.openSession().use { session ->
                val transaction = session.beginTransaction()
                println("--- persist")
                session.persist(Article("bike", BigDecimal(500), 10, 5))
                session.persist(Article("pump", BigDecimal(10), 10, 2))
                session.persist(Article("bow", BigDecimal(600), 4, 1))
                println("--- commit")
                transaction.commit()
            }

            // reading the table, with a new session
            println("--- reading the articles")
            sessionFactory.openSession().use { session ->
                val articles: List<Article> = session
                    .createSelectionQuery("from Article order by name", Article::class.java)
                    .resultList
                articles.forEach { println(it) }
            }
        }
}
  • lines 8–16: creation of the [SessionFactory]. There are several ways to configure Hibernate (we’ll discuss them later); we’re using the [HibernatePersistenceConfiguration] class from Hibernate 7, an enhanced version of the [PersistenceConfiguration] class introduced with Jakarta Persistence 3.2, which allows you to configure everything 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 10: the list of entities. [Article::class.java] is the Java object [Class] (the Kotlin class [KClass] is not suitable: Hibernate is a Java library);
  • Lines 12–13: the URL 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 15: [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 16: [createEntityManagerFactory] constructs [SessionFactory] (the method name follows the standard; the returned type is Hibernate’s). The function [use] will close it at the end of the program; within the block, it is called [sessionFactory];
  • Lines 19–20: The [SchemaManager] object of [SessionFactory] (Jakarta Persistence 3.2) manages the entity tables: [drop(false)] deletes them if they exist, while [create(false)] creates them (the parameter [false] 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. Kotlin presents the Java method [getSchemaManager()] as a property, [schemaManager] (Chapter 4);
  • line 23: [openSession()] opens a session, which is closed at the end of the [use] block;
  • line 24: the start of a transaction;
  • lines 26–28: [persist] adds three new items to the session: they will be stored in the database. No value is specified for the identifier; it will be generated by the DBMS;
  • line 30: [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 36–38: a query written in HQL, Hibernate’s query language: the articles in alphabetical order by name. It refers to the entity [Article] and its property [name], not to the table. The second parameter of [createSelectionQuery] is the result type: the property [resultList] (the Java method [getResultList]) returns a [List<Article>];
  • Line 39: Displaying the items.

If an exception were to occur between [beginTransaction] and [commit], the transaction would neither be committed nor explicitly rolled back by our code: we’ll see later a safer way to write a transaction using a higher-order function.

The [src/main/resources/logback.xml] file in the subproject 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, 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:

> gradlew -q :first:run
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 are named after the properties, and the column [id] is the primary key, with a generated value ([auto_increment]). The columns for properties of type [Int] are [not null]: compiled into [int]; a [Int] cannot equal [null]. Properties of type [String] and [BigDecimal] accept the value [NULL], even though these types are non-nullable in Kotlin: for Hibernate, these are Java references ([java.lang.String], [java.math.BigDecimal]), which can be set to [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 the 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 insertion (the JDBC driver returns the generated key; see Chapter 10). With other generation methods, insertions 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), using the parameterless constructor added by the [no-arg] plugin.

We did not write any SQL statements or any column-reading code: that is the whole point of a ORM. We can also see that this does not eliminate the need to understand 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 essentials 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 [@Id] annotation determines the access mode: when placed on a field, Hibernate reads and writes the fields directly; When placed on a getter or setter ([getId]), access goes through the getters and setters ([getX]/[setX]). Access via fields is the most common: it allows the class to have whatever methods it wants. In Kotlin, an annotation placed before a property is applied to the field (as we’ve seen): this provides access via fields without any explicit specification. For access via getters and setters, you would need to write [@get:Id];
  • any property that has a backing field is stored in a column, unless it bears the Jakarta Persistence annotation [@Transient] or the annotation [@kotlin.jvm.Transient] (which adds the Java modifier [transient] to the field); a calculated property, whose custom accessor does not use [field] (Chapter 4), has no field: it is never stored;
  • the table name is the entity name (the simple class name), and the column name is the property name. These names then go through 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 type compiled to a primitive type (non-nullable [Int], [Double], [Boolean]) becomes [NOT NULL]; a column of a type compiled to a reference ([Int?], [String], [BigDecimal], [LocalDate], whether nullable or not for Kotlin) accepts the value [NULL]. To make a column of a property of type [String] mandatory, you must therefore use the annotation [@Column(nullable = false)], even though the Kotlin type already prohibits the value [null];
  • The SQL types are selected based on 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]. An enumeration ([enum class]) is registered by default by its sequence number (see Chapter 3); the annotation [@Enumerated(EnumType.STRING)] registers it by its name, which is more reliable;
  • a property 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 subproject [model] defines two entities configured in two different ways. Its build file is that of [first], with the Logback dependency replaced by a dependency on the [tools] library described below (which provides Logback and its configuration). The first entity 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")
class Product(
    // required string of 50 characters at most
    @Column(nullable = false, length = 50)
    var name: String,

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

    // optional string (NULL column) of 200 characters at most
    @Column(length = 200)
    var description: String? = null,

    // an optional date: a nullable type
    var withdrawalDate: LocalDate? = null,
) {
    // the primary key: @Id, whatever its name
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var code: Int? = null

    // a property without a column
    @Transient
    var selected = false

    // a computed property has no field: it is never saved
    val priceInclTax: BigDecimal
        get() = priceExclTax * BigDecimal("1.2")
}
  • lines 3–9: the Jakarta Persistence annotations. The explicit import of [jakarta.persistence.Transient] takes precedence over the annotation [kotlin.jvm.Transient]—which has the same name—that Kotlin imports by default;
  • line 15: [@Table] specifies the table name;
  • lines 18–19: [@Column] specifies the column; [nullable = false] makes it required ([NOT NULL]); [length = 50] sets its length ([varchar(50)]);
  • lines 22–23: [@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)];
  • lines 26–27: an optional description of up to 200 characters: the column will accept the value [NULL]. The property is of type [String?], with the default value [null]: the Kotlin type matches the column. It is good practice to match the property’s nullable or non-nullable type with the column’s optional or required nature, even though Hibernate only reads the annotation;
  • line 30: an optional date. The type [LocalDate] (Chapter 5) represents a date without a time: it is associated with the types SQL and [date];
  • lines 32–35: the primary key, designated by [@Id] regardless of its name;
  • lines 38–39: [@Transient] excludes a property from the model: in this case, information useful to the user interface that does not belong in the database;
  • lines 42–43: a computed property. Its accessor does not use a field: there is nothing to save, and Hibernate, which accesses the fields, ignores it. The operator [*] between two [BigDecimal] is the function [times] that the Kotlin standard library adds to [BigDecimal] (Chapter 4, Operator Overloading): we write [priceExclTax * BigDecimal("1.2")] instead of Java’s [priceExclTax.multiply(new BigDecimal("1.2"))].

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
// the [jpa] and [allopen] plugins do not see it: it is declared [open]
// and has its own parameterless constructor
open class Supplier(
    private var companyName: String,
    private var phone: String?,
) {
    private var number: Int? = null
    private var creationDate: LocalDateTime? = null

    // the parameterless constructor required by Hibernate
    protected constructor() : this("", null)
}
  • Line 8: Since the class is not annotated with [@Entity], the [no-arg] and [all-open] plugins do not recognize it. So we declare it ourselves as [open], so that Hibernate can derive proxies from it;
  • lines 9–13: private properties: Kotlin generates neither accessors nor mutators for them, only a private field, which Hibernate will read from and write to. This is the exact equivalent of private fields in a Java class;
  • Line 16: The parameterless constructor required by Hibernate, written by hand: a secondary constructor that calls the primary constructor (Chapter 4) with arbitrary values, which Hibernate will replace with those read from the database. It is protected: the application code will not use it.

Its mapping to the table is described by a file named XML, located in the [src/main/resources/META-INF] folder of the subproject (this is the same file as in Java):

<?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(...)])] (in Kotlin, an array passed as an annotation argument is written in square brackets);
  • lines 14–16: the identifier, generated by the DBMS;
  • lines 17–18: a required column with a maximum of 60 characters;
  • lines 20–21: the property [phone] is stored in the column [tel];
  • lines 24–26: the creation date has a default value, calculated by the DBMS. The [column-definition] attribute 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 the 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 property, and this is the most common approach; the XML file, on the other hand, keeps the classes independent of Jakarta Persistence and centralizes 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 code, as other frameworks such as ORM (for example, Entity Framework in C# or Exposed in Kotlin) 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.SingularAttribute
import org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl
import org.hibernate.cfg.AvailableSettings
import org.hibernate.jpa.HibernatePersistenceConfiguration
import java.io.StringWriter

fun main() {
    // the table creation script will be written into this object
    val script = StringWriter()
    // a SessionFactory that never connects to the server
    HibernatePersistenceConfiguration("model")
        // an annotated entity and a mapping described in XML
        .managedClass(Product::class.java)
        .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.java.name)
        // 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().use { sessionFactory ->

            // the model built by Hibernate: entities and attributes (Jakarta Persistence API)
            for (entity in sessionFactory.metamodel.entities) {
                println("entity ${entity.name} (${entity.javaType.name})")
                for (attribute in entity.attributes) {
                    val id = attribute is SingularAttribute<*, *> && attribute.isId
                    println("  %-14s %s%s".format(attribute.name, attribute.javaType.simpleName,
                        if (id) " (identifier)" else ""))
                }
            }
        }
    // the table creation script, without the 4-space indentation of its lines
    println("--- creation script")
    script.toString().trim().lines()
        .map { it.removePrefix("    ") }
        .forEach { println(it.trimEnd()) }
}
  • line 11: a [StringWriter] (package [java.io]), a write stream to a string in memory, will receive the script SQL;
  • lines 15–16: the annotated entity is declared by [managedClass], and the file XML by [mappingFile];
  • lines 18–20: The database 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;
  • line 22: the “snake case” naming convention, denoted by the name of its class ([::class.java.name]);
  • lines 25–28: 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 32: the property [metamodel] (the [getMetamodel] method from Jakarta Persistence) provides access to the model: its entities ([EntityType]) and, for each one, its attributes ([Attribute]);
  • Lines 33–37: For each entity, its name and class; for each attribute, its name, Java type, and whether it is the identifier. On line 35, the [attribute is SingularAttribute<*, *>] test is followed by a smart type conversion (Chapter 4): in the continuation of the expression [&&], [attribute] is a [SingularAttribute], from which the property [isId] can be read. The [<*, *>] projection replaces Java’s [<?, ?>];
  • Lines 42–45: The script as displayed, without the four spaces that Hibernate adds to its lines ([removePrefix] removes the prefix only if it is present).

Results:

> gradlew -q :model:run
WARN  connection - HHH102003: No appropriate connection provider encountered; client must supply connections
entity Product (model.Product)
  code           Integer (identifier)
  description    String
  name           String
  priceExclTax   BigDecimal
  withdrawalDate LocalDate
entity Supplier (model.Supplier)
  number         Integer (identifier)
  companyName    String
  creationDate   LocalDateTime
  phone          String
--- 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 establish a connection;
  • lines 3–13: the two entities and their attributes (the identifier first, then the others in alphabetical order; the order of the entities is not significant). The property [selected] and the calculated property [priceInclTax] do not appear in the model. The types are the Java types: [Integer] for [Int?];
  • 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-specific choices for a 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 the DBMS, when inserting the row (column [AUTO_INCREMENT] of MySQL, [IDENTITY] of SQL Server, and PostgreSQL)
[SEQUENCE]
by a sequence from the 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] (by default)
Hibernate chooses; with MySQL, which has no sequences, it simulates them using a table named [<table>_SEQ]

With MySQL, we always 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 discuss batches later). The [SEQUENCE] strategy, which allows you to know the ID before the insertion, is preferred for the DBMS methods that use sequences.

In Kotlin, the generated identifier should preferably be declared as [var id: Int? = null]: the value [null] clearly indicates “not yet registered.” We also encounter [var id: Int = 0], where the value 0 serves this purpose; Hibernate accepts it (for an identifier of a primitive type, it treats 0 as the “not yet registered” value), but this convention is less readable.

11.3.4. Optional Values and Columns [NULL]

Whether a column is optional is therefore determined by the property’s type (compiled as a primitive type or a reference) and by the [nullable] attribute of [@Column]; in Kotlin, the property type also specifies whether the value may be missing ([String?]) or not ([String]). You must choose carefully and ensure that the two match: the rule expresses both a code constraint (“this value may be missing”) and a database constraint. A few notes:

  • [@Column(nullable = false)] represents the constraint [NOT NULL] from the schema created by Hibernate, and Hibernate also uses it before sending a command [INSERT] or [UPDATE]: if the field is [null], it does not send the command and throws a [PropertyValueException] error (“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 the DBMS, which rejects the command. In Kotlin, this scenario is less common than in Java: the compiler prohibits assigning [null] to a property of type [String];
  • for a more comprehensive validation performed before any database access, we use Jakarta Validation ([hibernate-validator] library) and its annotations [@NotNull], [@Size(max = 30)], [@Positive]... When present in the classpath, Hibernate validates entities before saving them and uses these annotations to create the columns. In Kotlin, these annotations also accept parameters as targets: when applied to a constructor property, Kotlin 2.4 applies them to both the parameter and the field, which the validator reads. Earlier versions placed them only on the parameter: you had to specify the field target, [@field:Size(max = 30)], a form that is still encountered in many examples;
  • there is no need to use [Optional] (Chapter 4): Kotlin’s nullable type ([var description: String? = null]) directly indicates that a value is optional, and it is read using the operators [?.] and [?:] (Chapter 3): [product.description ?: "(no 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 entries from Chapter 10. Rather than repeating the Hibernate entity and configuration in each subproject, we place them in a library—the [articles] subproject—on which the other subprojects 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] subproject.

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 the 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 org.slf4j.LoggerFactory

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

    // layout of a message: the text to write is returned ("": nothing is written)
    override fun doLayout(event: ILoggingEvent): String {
        val message = event.formattedMessage
        return when (event.loggerName) {
            SQL -> sqlStatement(message)
            PARAMETERS -> parameter(message)
            TRANSACTIONS -> transaction(message)
            ERRORS -> dbError(message)
            BATCHES -> batch(message)
            else -> ""
        }
    }

    companion object {
        // the Hibernate loggers used
        const val SQL = "org.hibernate.SQL"
        const val PARAMETERS = "org.hibernate.orm.jdbc.bind"
        const val TRANSACTIONS = "org.hibernate.orm.resource.jdbc"
        const val ERRORS = "org.hibernate.orm.jdbc.error"
        const val BATCHES = "org.hibernate.orm.jdbc.batch"

        // the maximum length of a clause written on a single line
        private const val WIDTH = 100

        // the execution of a batch of SQL statements: "Executing JDBC batch (3 / 3) - [...]"
        private val BATCH = Regex("""Executing JDBC batch \((\d+) / \d+\).*""")

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

        // the SQL statement formatted by Hibernate, made more compact: one line per clause (select, from...)
        private fun sqlStatement(sql: String): String {
            // the non-empty lines, without the trailing spaces
            val lines = sql.lines().map { it.trimEnd() }.filter { it.isNotBlank() }
            // the clauses: a top-level line (the least indented) and the lines that follow it
            val indentation = lines.minOfOrNull { indentation(it) } ?: 0
            val clauses = mutableListOf<MutableList<String>>()
            for (line in lines) {
                if (clauses.isEmpty() || indentation(line) == indentation) clauses.add(mutableListOf())
                clauses.last().add(line.substring(indentation))
            }
            // each clause is written on one line, if it is not too long
            var start = ""
            return buildString {
                for (clause in clauses) {
                    val line = start + clause.joinToString(" ") { it.trim() }
                    if (line == "insert" || line == "delete") {
                        // [insert into ...], [delete from ...]: a single line
                        start = "$line "
                        continue
                    }
                    if (line.length <= WIDTH) {
                        append("    SQL> ").append(line).append('\n')
                    } else {
                        // clause too long: the Hibernate lines are kept
                        clause.forEachIndexed { i, l ->
                            append("    SQL> ").append(if (i == 0) start else "").append(l).append('\n')
                        }
                    }
                    start = ""
                }
            }
        }

        // the number of spaces at the start of the line
        private fun indentation(line: String) = line.length - line.trimStart().length

        // the value of a parameter of the SQL statement
        private fun parameter(message: String): String =
            PARAMETER.matchEntire(message)
                ?.let { "    SQL> -- parameter ${it.groupValues[1]} = ${it.groupValues[2]}\n" } ?: ""

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

        // the error reported by the DBMS (the line that gives its error codes is ignored)
        private fun dbError(message: String): String =
            if (message.startsWith("HHH000247")) "" else "    SQL> -- failure: $message\n"

        // the execution of a batch of SQL statements (the other messages are ignored)
        private fun batch(message: String): String =
            BATCH.matchEntire(message)
                ?.let { "    SQL> -- execution of a batch of ${it.groupValues[1]} statement(s)\n" } ?: ""

        // executes an action without an SQL log and returns its result
        fun <T> withoutLog(action: () -> T): T {
            val loggers = listOf(SQL, PARAMETERS, TRANSACTIONS, ERRORS, BATCHES)
                .map { LoggerFactory.getLogger(it) as Logger }
            // the current levels, restored at the end
            val levels: List<Level?> = loggers.map { it.level }
            loggers.forEach { it.level = Level.OFF }
            try {
                return action()
            } finally {
                loggers.zip(levels).forEach { (logger, level) -> logger.level = level }
            }
        }
    }
}
  • line 10: a Logback layout derives from the Java class [LayoutBase<ILoggingEvent>] (note the parentheses: the call to the parent class’s constructor, Chapter 4); its method [doLayout] (lines 13–21) receives each message (a [ILoggingEvent]) and returns the text to be written. Logback creates the object itself, via reflection, based on the class name: it requires a parameterless constructor, which is the class’s implicit primary constructor;
  • lines 15–21: the processing depends on the log that sent the message (a ***[when] on a channel, Chapter 3). The branches use the constants from ***[companion object];
  • line 25: the class’s “static” members are stored in its companion object (Chapter 4);
  • lines 27–31: the names of the Hibernate logs used and the public compilation constants ([const val]); other subprojects refer to them as [SqlLog.SQL]...;
  • lines 37–40: two regular expressions (Chapter 5), written as raw strings (["""..."""]), which avoids having to double the backslashes, as Java does (["\\((\\d+)..."]);
  • lines 43–71: the order SQL, as formatted by Hibernate ([format_sql]), spread across many lines, with one column per line. We make it more compact. Non-empty lines are grouped into clauses (lines 47–51): a clause begins with a top-level line ([select], [from], [where]...) and includes the indented lines that follow it. [minOfOrNull] specifies the smallest indentation, or [null] if there is no line, in which case it is replaced by 0 using the operator [?:]. Each clause is then written on a single line, preceded by [SQL>], if it does not exceed 100 characters (lines 63–64); otherwise, its lines are retained (lines 67–68). The clauses [insert] and [delete] are combined with the following one (lines 58–61). The text is constructed by [buildString] (Chapter 5), whose block has a [StringBuilder] as its receiver: [append] is called there without a prefix;
  • lines 80–82: the Hibernate message that returns the value of a parameter resembles [binding parameter (1:VARCHAR) <- [bike]]. [matchEntire] returns a [MatchResult] if the entire message matches the regular expression, and [null] otherwise; [?.let] constructs the text from the captured groups (the parameter number and its value), and [?: ""] returns an empty string when there is no match;
  • Lines 85–89: 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. A [when] without arguments (Chapter 3) replaces Java’s [if] sequence;
  • lines 93–94: an error in the DBMS. Hibernate writes this as two messages: one providing the error codes (which we ignore) and the other providing the error text;
  • lines 97–99: execution of a batch of SQL commands, which we will encounter at the end of the chapter;
  • lines 102–111: the function [withoutLog] executes a logless action SQL. This is a higher-order function (Chapter 7): its parameter is a function [() -> T], and it returns the result of the action (Java, which uses a [Runnable], does not allow this quite so easily). It uses Logback’s API: each log ([Logger]) has a level, which can be modified at runtime (the [level] property, which corresponds to the Java methods [getLevel]/[setLevel]). 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. The original level may be [null] (a log that inherits its level from its parent), hence the type [List<Level?>]; [zip] associates each log with its original level, and the [(logger, level)] decomposition (Chapter 3) names the two elements of each pair;
  • line 104: [LoggerFactory] is the class of SLF4J that returns a log; Since Logback is the current implementation of SLF4J, the rendered object is a [ch.qos.logback.classic.Logger], as indicated by the type conversion [as Logger].

The [tools] subproject also contains the [logback.xml] file used by the examples (it is the same file as in Java). 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>

    <!-- Hibernate's built-in 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 layout, identified by its class name ([tools.SqlLog]);
  • lines 13–17: a second “appender” for the other messages: level, short log name, text;
  • Lines 20–34: The Hibernate logs from the previous table, along with their levels. Their messages are sent to the “appender” [SQL] and only to it ([additivity="false"]: they are not additionally forwarded to the root log);
  • line 37: the warning regarding 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 build file for the [tools] subproject is that of a library (Chapter 6):

// library: the compact log of the SQL statements executed by Hibernate
plugins {
    kotlin("jvm")
    // the library plugin: it adds the [api] configuration
    `java-library`
}

repositories {
    mavenCentral()
}

dependencies {
    // Logback, which will receive the Hibernate log (JBoss Logging detects it);
    // [api]: the SqlLog class derives from a Logback class, its users must see it
    api("ch.qos.logback:logback-classic:1.5.38")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}
  • line 5: the library plugin, which adds the [api] configuration;
  • line 15: the only dependency, Logback. It is declared by [api] and not by [implementation]: the class [SqlLog] derives from a Logback class, and the subprojects that use it must be able to see this parent class at compile time. A dependency on [api] is provided to users of the library, for both compilation and runtime.

A library does not typically impose its logging configuration on the applications that use it; we are doing 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] subproject uses the annotations from the previous paragraph:

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
// an ordinary class (not a data class); the [jpa] plugin adds a parameterless constructor to it,
// the [allopen] plugin makes it open (non-final), as well as its properties
@Entity
@Table(
    name = "articles",
    uniqueConstraints = [UniqueConstraint(name = "uk_articles_name", columnNames = ["name"])]
)
class Article(
    // name of the article: mandatory, at most 30 characters, unique (see @Table)
    @Column(nullable = false, length = 30)
    var name: String,
    // price: 10 digits of which 2 after the decimal point
    @Column(nullable = false, precision = 10, scale = 2)
    var price: BigDecimal,
    // current stock and minimum stock below which restocking is needed
    var currentStock: Int,
    var minimumStock: Int,
) {
    // primary key, generated by the DBMS (AUTO_INCREMENT): null as long as the article is not saved
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        // no modification by the application: the identifier is managed by Hibernate
        protected set

    // display
    override fun toString() = "[%d, %s, %.2f, %d, %d]".format(id, name, price, currentStock, minimumStock)
}
  • lines 13–14: a reminder of what the [jpa] and [allopen] plugins do for this class;
  • lines 16–18: 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. Array-type annotation parameters are written in Kotlin within square brackets ([[UniqueConstraint(...)]], [["name"]]); an annotation used as an argument for another annotation is written without [@];
  • lines 22–23: a required name of no more than 30 characters;
  • lines 25–26: a price in the format [decimal(10,2)];
  • lines 28–29: the two stock levels, of type [Int]: columns [NOT NULL];
  • lines 32–36: the identifier. The property modifier is declared as [protected]: the application can read the identifier but cannot change it, since it is managed by Hibernate (the Java class does not have a [setId] method, for the same reason). It could not be [private]: the property is public ([all-open]), and Kotlin prohibits a private modifier on a public property, which a derived class could redefine (“[private setters for open properties are prohibited.]”);
  • line 39: the same method [toString] as in the first example.

The Java version of this class has nearly ninety lines, including the two constructors and about ten accessors ([getX]/[setX]); in Kotlin, the properties of the primary constructor and the two plugins serve this purpose.

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

package articles

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

// the [hbarticles] database: Hibernate configuration and database initialization
// an [object]: a single instance, without a constructor (Java's utility class)
object BaseArticles {

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

    // the Hibernate configuration: entities, database, formatting of the logged SQL
    fun configuration(): HibernatePersistenceConfiguration =
        HibernatePersistenceConfiguration("articles")
            .managedClass(Article::class.java)
            .jdbcUrl(URL)
            .jdbcCredentials(USER, PASSWORD)
            .property(AvailableSettings.FORMAT_SQL, true)

    // a SessionFactory for the [hbarticles] database, with the previous configuration
    fun createSessionFactory(): SessionFactory = configuration().createEntityManagerFactory()

    // (re)creation of the [articles] table with its six articles, without an SQL log
    fun initialize(sessionFactory: SessionFactory) = SqlLog.withoutLog {
        // dropping then creating the tables of the entities
        sessionFactory.schemaManager.drop(false)
        sessionFactory.schemaManager.create(false)
        // the six articles, added in a transaction
        sessionFactory.inTransaction { session ->
            session.persist(Article("bike", BigDecimal(500), 10, 5))
            session.persist(Article("pump", BigDecimal(10), 10, 2))
            session.persist(Article("bow", BigDecimal(600), 4, 1))
            session.persist(Article("arrows - set of 6", BigDecimal(100), 12, 20))
            session.persist(Article("diving suit", BigDecimal(300), 8, 2))
            session.persist(Article("diver's oxygen tanks", BigDecimal(120), 10, 5))
        }
    }
}
  • line 11: a [object] (Chapter 4): a singleton, created on first use. This is equivalent to a Java utility class (a [final] class with static members and a private constructor);
  • lines 14–16: the base URL and the user URL, which are public so that other subprojects can use them;
  • Lines 19–24: The Hibernate configuration. It is returned by a public function, which will allow an example to complete it before creating the [SessionFactory]. The [FORMAT_SQL] ([hibernate.format_sql]) property instructs Hibernate to format the SQL, which it then passes to the [org.hibernate.SQL] log: it is this formatted output that the [SqlLog] class then condenses;
  • line 27: the [SessionFactory] from the [hbarticles] base, a function whose body is an expression;
  • lines 30–41: a function that recreates the table [articles] with its six entries, without the log SQL: we do not want to see the SQL from this initialization in the example results. The block passed to [withoutLog] is a lambda written after the parentheses, which are omitted here (Chapter 7);
  • line 35: [sessionFactory.inTransaction { session -> ... }] opens a session, starts a transaction, executes the lambda 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 unit of work; 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 parameter for these Java methods is a functional interface ([Consumer<Session>], [Function<Session, R>]): Kotlin directly accepts a lambda there (conversion SAM, Chapter 7);
  • lines 36–41: [BigDecimal(500)] calls the constructor of [BigDecimal], which takes an integer; it returns the same value as the Java method [BigDecimal.valueOf(500)].

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

// library: the [Article] entity and the factory of the SessionFactory of the [hbarticles] database
plugins {
    kotlin("jvm")
    // the parameterless constructor of the entities ("no-arg" plugin configured for JPA)
    kotlin("plugin.jpa")
    // entities and properties made open (non-final): Hibernate can derive proxies from them
    kotlin("plugin.allopen")
    // the library plugin: it adds the [api] configuration
    `java-library`
}

repositories {
    mavenCentral()
}

dependencies {
    // the compact SQL log (and Logback)
    api(project(":tools"))
    // Hibernate: [api], because the users of the library handle SessionFactory, Session...
    api("org.hibernate.orm:hibernate-core:7.4.5.Final")
    // the MySQL JDBC driver: only useful at runtime
    runtimeOnly("com.mysql:mysql-connector-j:9.7.0")
}

// the classes annotated this way are opened by the [allopen] plugin
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}
  • lines 5–9: the library defines an entity; it applies both entity plugins and the library plugin;
  • line 18: the dependency on another subproject in the build, identified by its path ([":tools"]);
  • line 20: Hibernate, declared by [api]: subprojects that use the library directly manipulate its types ([SessionFactory], [Session]...);
  • line 22: the driver JDBC, required only at runtime;
  • lines 26–29: the configuration for the [all-open] plugin.

The example subprojects then require only one dependency: the [articles] subproject. Gradle adds its dependencies (known as transitive dependencies): the [api] dependencies for compilation and execution, and the [runtimeOnly] dependency for execution. Here is the build file for the [crud] subproject:

// the basic operations: persist, find, merge, remove; the states of an entity
plugins {
    kotlin("jvm")
    // no [jpa] or [allopen] plugin: the subproject does not define any entity
    // (the Article class is compiled in the [articles] subproject)
    application
}

repositories {
    mavenCentral()
}

dependencies {
    // the [Article] entity and the [hbarticles] database (Hibernate, MySQL and the SQL log included)
    implementation(project(":articles"))
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the program run by [gradlew -q :crud:run], launched in the 11-hibernate folder
    mainClass = "crud.MainKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • line 4: the subproject does not define any entities: it does not need the plugins [jpa] and [allopen], which are used when compiling annotated classes, in this case those from the [articles] subproject;
  • line 15: the only dependency;
  • Lines 31–34: The programs launched by Gradle write their output to UTF-8 so that accented characters display correctly on Windows (Chapter 2).

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 by 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: [Configuration().addAnnotatedClass(Article::class.java).buildSessionFactory()]. This is the approach used in older versions of Hibernate;
  • In a Spring Boot application, the properties [spring.datasource.*] and [spring.jpa.*] in 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] subproject 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 functions at the end of the file, written as extension functions of [Session] (Chapter 4). Transactions are written using the session’s [inTransaction] method, a higher-order function: [session.inTransaction { ... }] starts a transaction, executes the block, and then commits the transaction or rolls it back if the block throws an exception. The code can no longer omit [commit] or [rollback] in the event of an error.

package crud

import articles.Article
import articles.BaseArticles
import org.hibernate.Session
import org.hibernate.engine.spi.PersistenceContext
import org.hibernate.engine.spi.SessionImplementor
import java.math.BigDecimal

fun main() {
    BaseArticles.createSessionFactory().use { sessionFactory ->
        // the [articles] table is recreated with its six articles
        BaseArticles.initialize(sessionFactory)
        sessionFactory.openSession().use { session ->
            // 1 - search by the primary key
            println("--- find(1)")
            val bike = session.find(Article::class.java, 1) ?: error("no article no. 1")
            println(bike)
            println("--- find(1) a second time")
            val bike2 = session.find(Article::class.java, 1)
            println("same object: ${bike === bike2}")

            // 2 - adding an article, in a transaction
            println("--- persist")
            val x = Article("x", BigDecimal(100), 10, 1)
            session.inTransaction {
                println("before persist: state=${session.state(x)}, id=${x.id}")
                session.persist(x)
                println("after persist: state=${session.state(x)}, id=${x.id}")
            }

            // 3 - modification: it is enough to modify the object
            println("--- modification of the price")
            session.inTransaction {
                x.price *= BigDecimal("1.1")
                println("session modified: ${session.isDirty}, " +
                    "original price=${session.originalValue(x, "price")}, current price=${x.price}")
                println("--- commit")
            }
            println("after commit: session modified: ${session.isDirty}")

            // 4 - deletion
            println("--- remove")
            session.inTransaction {
                session.remove(x)
                println("before commit: state=${session.state(x)}")
            }
            println("after commit: state=${session.state(x)}")

            // 5 - a detached object, modified then merged with the session
            println("--- detach and merge")
            val bow = session.find(Article::class.java, 3) ?: error("no article no. 3")
            session.detach(bow)
            println("after detach: state=${session.state(bow)}")
            bow.price = BigDecimal(550)
            session.inTransaction {
                val managedBow = session.merge(bow)
                println("merge: same object=${bow === managedBow}, " +
                    "state of the returned object=${session.state(managedBow)}")
            }

            // 6 - the entities tracked by the session
            println("--- the tracked entities")
            bike.currentStock--
            session.displayTrackedEntities()
        }
    }
}

// the persistence context of the session (Hibernate internal API): an extension property
private val Session.trackingContext: PersistenceContext
    get() = unwrap(SessionImplementor::class.java).persistenceContext

// the state of an article with respect to the session (Hibernate internal API)
private fun Session.state(article: Article): String = when {
    // an object tracked by the session: MANAGED, DELETED...
    trackingContext.isEntryFor(article) -> trackingContext.getEntry(article).status.name
    // an object unknown to the session: never saved (without an identifier) or detached
    article.id == null -> "TRANSIENT"
    else -> "DETACHED"
}

// the value of a property as it was read from the database (Hibernate internal API)
private fun Session.originalValue(entity: Any, property: String): Any? =
    trackingContext.getEntry(entity).getLoadedValue(property)

// the entities of the persistence context of the session (Hibernate internal API)
private fun Session.displayTrackedEntities() {
    for ((entity, entry) in trackingContext.reentrantSafeEntityEntries()) {
        val persister = entry.persister
        println("${persister.entityName} #${entry.id} ${entry.status}")
        // current values of the properties, compared with the values read from the database
        val current = persister.getValues(entity)
        val originals = entry.loadedState
        persister.propertyNames.forEachIndexed { i, name ->
            val modification = if (current[i] == originals[i]) ""
                else " (modified, original value: ${originals[i]})"
            println("    $name : ${current[i]}$modification")
        }
    }
    println("session modified: $isDirty")
}

Results:

> gradlew -q :crud:run
--- 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 17: [session.find(Article::class.java, 1)] searches for the primary key record 1. It returns [null] if the key does not exist; the operator [?:] then throws an exception ([error], Chapter 3), and [bike] is of type [Article], which is non-nullable. The generated SQL (lines 3–6 of the results) is a parameterized [SELECT], whose parameter is set to 1: the compact log now displays the parameter values;
  • line 20: the second call to [find(Article::class.java, 1)] does not send any SQL commands and returns the same object (line 9). The [===] operator compares references (the Java [] operator), whereas [] would call [equals] (Chapter 3). 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 25–29: 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] (Hibernate writes to the field directly: the property’s protected modifier does not prevent this). The item is then [MANAGED] (“managed” or “persistent”), and its identifier is 7 (line 19). The transaction is opened at the beginning of the block [inTransaction] and committed at its end (lines 11 and 20 of the results). The block parameter ([Transaction]) is not used: it is not named;
  • line 35: to modify a row in the database, simply modify the object. No [update] method is required: the session has retained a copy of the read or saved values (the original state) and compares the current values to those. This is known as “dirty checking.” The [*=] operator is translated here by Kotlin into [x.price = x.price * BigDecimal("1.1")]: a new [BigDecimal] object (which is immutable) is assigned to the property;
  • Lines 36–37: The property [isDirty] (the Java method [isDirty()]) indicates whether the session contains changes that have not yet been committed to the database: this is the case (line 23). The function [originalValue] (lines 84–85) reads the original price value, 100, from the persistence context, whereas the current value is 110.0;
  • line 38: the end of the block. The subsequent [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 modifications. In contrast, the [@DynamicUpdate] annotation placed on the entity instructs it to generate a query limited to the modified columns each time;
  • Lines 45–48: deletion. [remove] changes the article’s status 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 52–55: 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 57: [session.merge(bow)] merges the detached object with the session. The session reads the item 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] records the new price (lines 55–62);
  • line 64: the bike’s inventory is updated, without a transaction;
  • lines 71–72: the session’s persistence context, obtained via an extension property: in the following functions, [trackingContext] is treated as a session property. [unwrap] provides access to the session’s internal interface [SessionImplementor];
  • lines 75–80: the status of an item, calculated by a [when] without arguments. First, [isEntryFor] is used to check whether the session is associated with the item: the [getEntry] method, which returns [null] for an unknown object (which is what the Java code tests for), is nevertheless annotated as [@NonNull] in Hibernate. Kotlin takes these nullability annotations from the Java libraries into account: it considers that [getEntry] returns a non-nullable [EntityEntry] and flags a check for [?.] on its result (“[unnecessary safe call on a non-null receiver of type 'EntityEntry'.]”) as unnecessary ( a warning, and thus an error with our compilation options). This is a rare but instructive case: the types in a Java library are always exactly what their annotations specify;
  • Lines 88–101: display of the persistence context. For each tracked entity ([EntityEntry]), its name, identifier, and state are displayed, followed by the current value of each property and its original value if it differs. 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. The [for ((entity, entry) in ...)] loop unpacks each element of the array, a [Map.Entry] (Chapter 5); [forEachIndexed] iterates over the property names along with their indices. The [==] operator between two values—which can be [null]—replaces Java’s [Objects.equals]. In an extension function, [isDirty] refers to the receiver’s property, the session;
  • Lines 65–75 of the results: The session tracks the bike, whose stock 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 a constructor call and has never been registered: 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])

Synchronizing (“flushing”) the session sends the SQL commands to the 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 saved 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 request in a web application. You do not use the same session throughout the entire lifetime of an application—it would accumulate the tracked entities in its persistence context.

In Kotlin, this cycle is naturally implemented using Hibernate’s higher-order functions: [sessionFactory.inTransaction { session -> ... }] for a complete unit of work (session and transaction), [sessionFactory.fromTransaction { session -> ... }] when it returns a result, and [session.inTransaction { ... }] for a transaction within an already open session. In short, these are the equivalents of the [use] function for transactions.

11.6. The 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 it is the DBMS that 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 Kotlin. With our Kotlin entities, an attribute’s name is the same as the property’s name (Hibernate accesses the field that stores it, and that field is named after the property);
  • 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, which receives the text HQL and the result type ([Article::class.java], the Java class of the entity), and returns a [SelectionQuery<T>] object. You can then assign values to its parameters and execute it with [getResultList] (a list), [getSingleResult] (a single result; an exception if there are zero or more), [getSingleResultOrNull] ([null] if there are none), or [getResultStream] (a stream). As is always the case with Java classes, Kotlin allows you to write these parameterless [getXxx] methods as properties: [query.resultList], [query.singleResult], [query.singleResultOrNull]. The [createQuery] method from Jakarta Persistence also exists; it is used for both read queries and updates.

11.6.2. The program [hql]

The [hql] subproject provides several examples of queries. It has only one dependency: the [articles] library (which provides Hibernate, the MySQL driver, and the compact SQL log); it does not define any entities, and therefore does not require either the [jpa] plugin or the [allopen] plugin:

// HQL / JPQL queries
plugins {
    kotlin("jvm")
    // no [jpa] or [allopen] plugin: no entity here (Article is compiled in [articles])
    application
}

repositories {
    mavenCentral()
}

dependencies {
    // the [Article] entity and the [hbarticles] database (Hibernate, MySQL and the SQL log included)
    implementation(project(":articles"))
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the program run by [gradlew -q :hql:run]
    mainClass = "hql.MainKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}

The program:

package hql

import articles.Article
import articles.BaseArticles
import org.hibernate.query.SemanticException
import java.math.BigDecimal

// HQL / JPQL queries on the [articles] table

// a projection: the name of an article and the value of its stock (a data class, not an entity)
data class StockValue(val name: String, val value: BigDecimal)

// a business rule written in Kotlin (an extension function):
// an article is out of stock if its stock is below the minimum stock
fun Article.isOutOfStock() = currentStock < minimumStock

fun main() {
    BaseArticles.createSessionFactory().use { sessionFactory ->
        // the [articles] table is recreated with its six articles
        BaseArticles.initialize(sessionFactory)
        sessionFactory.openSession().use { session ->
            // 1 - filtering and sorting
            println("--- the articles to restock, in alphabetical order")
            val hql = "from Article where currentStock < minimumStock order by name"
            val toOrder = session.createSelectionQuery(hql, Article::class.java).resultList
            toOrder.forEach(::println)

            // 2 - projection: only the useful columns are requested
            println("--- value of the stock of each article")
            val values = session
                .createSelectionQuery(
                    """
                    select name, price * currentStock from Article
                    order by price * currentStock desc""", StockValue::class.java
                )
                .resultList
            for ((name, value) in values) {
                println("%s : %.2f euros".format(name, value))
            }

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

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

            // 5 - like and in; a Kotlin list as the value of a parameter
            println("--- like and in")
            val names = listOf("bow", "pump", "racket")
            val results = session
                .createSelectionQuery(
                    """
                    select name from Article
                    where name like 'b%' or name in :names""", String::class.java
                )
                .setParameter("names", names)
                .resultList
            println(results.joinToString(", "))

            // 6 - pagination: the third page of two articles, by ascending price
            println("--- pagination")
            val pageSize = 2
            val pageNumber = 3
            val page = session.createSelectionQuery("from Article order by price", Article::class.java)
                .setFirstResult((pageNumber - 1) * pageSize)
                .setMaxResults(pageSize)
                .resultList
            val text = page.joinToString(", ") { "%s (%.2f euros)".format(it.name, it.price) }
            println("page $pageNumber : $text")

            // 7 - an incorrect query is rejected as soon as it is created, before any execution
            println("--- incorrect query")
            try {
                session.createSelectionQuery("from Article where stock < minimumStock", Article::class.java)
            } catch (e: SemanticException) {
                println("${e.javaClass.simpleName} : ${e.message}")
            }

            // 8 - a rule written in Kotlin cannot be translated into SQL: in-memory filtering
            println("--- in-memory filtering")
            session.createSelectionQuery("from Article", Article::class.java).resultStream.use { stream ->
                stream.filter { it.isOutOfStock() }.forEach(::println)
            }
        }
    }
}

Results:

> gradlew -q :hql:run
--- 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 24–26: The clause [where] compares two attributes of the entity; the clause [order by] sorts by name: they become the clauses [WHERE] and [ORDER BY] in the order SQL. The query is executed by [resultList] (the [getResultList] method), which returns a [List<Article>]; [forEach(::println)] displays each item using its method [toString];
  • Lines 30–38: a projection. The query does not return entities, but rather two values per item: the name and the stock value. The generated SQL retrieves only the necessary columns and performs the calculation itself. The result type is the [StockValue] data class from line 11: Hibernate creates one object per row, passing the values from the [select] clause to its constructor in order (the Java course used a record). These objects are not entities: the session does not track them. A data class is perfectly suited for this role, and we take advantage of its destructuring: [for ((name, value) in values)] (line 37);
  • lines 32–34: the query is written in a raw string (["""..."""], Chapter 5), which is more readable than a long string on a single line. Unlike Java text blocks, a raw string preserves its line indentation and the initial line break; this is irrelevant here, since spaces and line breaks do not matter in HQL: there is no need to call [trimIndent];
  • lines 44–53: 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 (the [?] from the clause [WHERE], whose value is displayed by the log): so there is no need to worry about SQL injections, since the value is passed separately from the order text. This is the rule that Chapter 10 applied to prepared statements. [setMaxResults(1)] limits the result to a single row: the [LIMIT] clause of MySQL. [singleResultOrNull] returns a [Article?]: the nullable type clearly states that there may be no result, and the Elvis operator [?:] displays [none] in this case;
  • It’s important to highlight a pitfall specific to Kotlin. Its string templates make it very tempting to write ["... where a.price > $minPrice"]. The program would work, but the value would then be copied into the query text: it is precisely this concatenation that opens the door to injections (Chapter 10), and Hibernate would have to parse a new query for each value. We never use the character [$] to set a value in a query HQL: values are passed via named parameters. A literal value written in the query text (such as ['b%'] on line 72) is also copied into the text of the SQL command, as shown in the log: this is acceptable for a program constant, but never for a value coming from an external source;
  • Lines 57–63: The aggregates [count] and [sum] are calculated by the DBMS: only the result is transferred, not the rows. [Long::class.java] refers to the class of the primitive type [long] in Java (the Kotlin type [Long] is compiled to [long]), whereas the Java course used [Long.class], the wrapper class [java.lang.Long]; Hibernate accepts both (the wrapper class would be written as [Long::class.javaObjectType]). HQL also recognizes [avg], [min], [max], the clauses [group by] and [having];
  • lines 67–76: the [like] operator of SQL, and the [in] operator, whose operand is a parameter that accepts a Kotlin list (a Kotlin [List] is a Hibernate [java.util.List]): Hibernate expands it into as many SQL parameters as there are elements in the list. HQL supports numerous functions on strings ([upper], [length], [substring]...), dates, and numbers, which it translates into the DBMS dialect;
  • lines 82–85: 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, whose two parameters are 4 and 2. Pagination only makes sense on a sorted list ([order by]). Lines 86–87 construct the page display using [joinToString] and its transformation function (Chapter 5);
  • lines 91–94: a query that uses a [stock] attribute 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] (Hibernate messages are in English). A syntax error would result in a [SyntaxException]. Note that the value returned by [createSelectionQuery] is ignored: in Kotlin, there is no requirement to use the result of a function;
  • lines 15 and 99–100: a query cannot use a Kotlin function, such as the extension function [isOutOfStock]: Hibernate does not know its 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: [resultStream] (the [getResultStream] method) returns a Java [Stream<Article>], which holds a JDBC resource open and must be closed after use: the [use] function handles this, since a [Stream] is a [AutoCloseable]. The result is correct, but the entire table was transferred (the last SQL order has no [WHERE] clause): with a large table, this should be avoided. It’s better to write the condition in the query, as shown on line 24. The extension function also demonstrates a very “Kotlin”-like way of writing a business rule on an entity without modifying its class (Chapter 4).

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 [@NamedQuery(name = "Article.outOfStock", query = "from Article where currentStock < minimumStock")] annotation, applied to 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.java)]. It is written in Kotlin exactly the same way as in Java; the annotation is repeatable: it is applied as many times as there are queries to declare;
  • the Hibernate annotation processor (“Hibernate Processor,” library [org.hibernate.orm:hibernate-processor]). In Java, it is executed by [javac] during compilation: it checks the queries in the annotations and generates, for each entity, a “metamodel” class ([Article_] for [Article]), which we’ll see again with the API Criteria. This mechanism does not apply solely to Kotlin: we’ll return to this below when discussing the static metamodel.

11.7. The API Criteria

11.7.1. Queries Built with Code

A HQL query is a string that can be constructed through concatenation (or, in Kotlin, using templates): this approach 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 built through method calls. Hibernate translates this tree into SQL, just like a HQL query (it also translates HQL into such a tree). The subproject [criteria] (its [build.gradle.kts] is that of the subproject [hql], except for the program name) includes several queries from the previous program:

package criteria

import articles.Article
import articles.BaseArticles
import jakarta.persistence.criteria.Path
import jakarta.persistence.criteria.Predicate
import org.hibernate.Session
import java.math.BigDecimal
import kotlin.reflect.KProperty1

// queries built with the Criteria API

// a projection: the name of an article and the value of its stock
data class StockValue(val name: String, val value: BigDecimal)

// an attribute designated by a property reference (Article::price), and not by a string ("price"):
// the name is checked by the compiler, and the type of the returned path is that of the property
fun <X, T> Path<X>.get(property: KProperty1<X, T>): Path<T> = get(property.name)

fun main() {
    BaseArticles.createSessionFactory().use { sessionFactory ->
        // the [articles] table is recreated with its six articles
        BaseArticles.initialize(sessionFactory)
        sessionFactory.openSession().use { session ->
            // the factory of the query elements
            val cb = session.criteriaBuilder

            // 1 - filtering and sorting; the attributes are designated by strings
            println("--- the articles to restock, in alphabetical order")
            val query = cb.createQuery(Article::class.java)
            val a = query.from(Article::class.java)
            query.select(a)
                .where(cb.lt(a.get<Int>("currentStock"), a.get<Int>("minimumStock")))
                .orderBy(cb.asc(a.get<String>("name")))
            session.createSelectionQuery(query).resultList.forEach(::println)

            // 2 - projection to a data class; the attributes are designated by references
            println("--- value of the stock of each article")
            val projection = cb.createQuery(StockValue::class.java)
            val p = projection.from(Article::class.java)
            val value = cb.prod(p.get(Article::price), p.get(Article::currentStock))
                .`as`(BigDecimal::class.java)
            projection.select(cb.construct(StockValue::class.java, p.get(Article::name), value))
                .orderBy(cb.desc(value))
            for ((name, v) in session.createSelectionQuery(projection).resultList) {
                println("%s : %.2f euros".format(name, v))
            }

            // 3 - a query built according to the criteria supplied
            println("--- search: maximum price 150 euros")
            search(session, maxPrice = BigDecimal(150)).forEach(::println)
            println("--- search: maximum price 150 euros and name starting with [d]")
            search(session, maxPrice = BigDecimal(150), nameStart = "d").forEach(::println)

            // 4 - aggregates
            println("--- aggregates")
            val count = cb.createQuery(Long::class.java)
            count.select(cb.count(count.from(Article::class.java)))
            val nbArticles = session.createSelectionQuery(count).singleResult
            val sum = cb.createQuery(BigDecimal::class.java)
            val s = sum.from(Article::class.java)
            val articleValue = cb.prod(s.get(Article::price), s.get(Article::currentStock))
                .`as`(BigDecimal::class.java)
            sum.select(cb.sum(articleValue))
            val stockValue = session.createSelectionQuery(sum).singleResult
            println("%d articles, value of the stock: %.2f euros".format(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
fun search(session: Session, maxPrice: BigDecimal? = null, nameStart: String? = null): List<Article> {
    val cb = session.criteriaBuilder
    val query = cb.createQuery(Article::class.java)
    val a = query.from(Article::class.java)
    // the conditions, added one by one
    val conditions = buildList<Predicate> {
        maxPrice?.let { add(cb.le(a.get(Article::price), it)) }
        nameStart?.let { add(cb.like(a.get(Article::name), "$it%")) }
    }
    query.select(a).where(conditions).orderBy(cb.asc(a.get(Article::price)))
    return session.createSelectionQuery(query).resultList
}

Results:

> gradlew -q :criteria:run
--- 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 26: the session returns a [HibernateCriteriaBuilder] ([criteriaBuilder], the [getCriteriaBuilder] method), the Hibernate-enhanced version of Jakarta Persistence’s [CriteriaBuilder]: This is the factory for all elements of a query (conditions, expressions, sorts);
  • lines 30–34: the query from the first example, HQL. [createQuery(Article::class.java)] creates a query that returns articles; [from(Article::class.java)] declares the queried entity and returns its root ([Root<Article>]), which is the equivalent of the alias [a] for [from Article a]; [a.get<Int>("currentStock")] designates an attribute. The condition is constructed by [cb.lt(x, y)] (“less than,” [x < y]), and the sort by [cb.asc(...)]. The explicit type argument [<Int>] (in Java: [a.<Integer>get(...)]) specifies the attribute’s type, which cannot be determined from the string ["currentStock"]. The compiler cannot verify that it is correct: it takes our word for it;
  • line 35: the query is executed by the session, as a HQL query. The generated SQL is the same as that of the HQL query;
  • line 18: an extension function of the Jakarta Persistence interface [Path], which refers to an attribute using a property reference ([Article::price], of type [KProperty1<Article, BigDecimal>], Chapter 7) rather than a string. It simply passes the property name ([property.name], here ["price"]) to the original [get] method, but it provides two guarantees: the compiler verifies that the property exists, and the type of the rendered path ([Path<BigDecimal>]) is inferred from that of the property, without an explicit type argument. This relies on the fact that the attribute name JPA is the same as the Kotlin property name, which is the case for our entities;
  • Lines 39–46: a projection to the data class [StockValue], using the extension function: [p.get(Article::price)]. [cb.prod(x, y)] is the product of two expressions; [as(BigDecimal::class.java)] specifies the result type ([as] is a Kotlin keyword: to call the Java method with this name, it is enclosed in grave accents); [cb.construct(StockValue::class.java, ...)] indicates that each line of the result must be passed to the data class’s constructor;
  • Lines 73–83: This is where the API Criteria function is most useful. The [search] function constructs a query based on the criteria provided. Its criteria are nullable parameters with a default value ([maxPrice: BigDecimal? = null]), which allows for the named-argument calls in lines 51 and 53 (in Java, [null] had to be passed explicitly). The list of conditions is constructed by [buildList]: each condition is added to it only if the corresponding criterion is not [null] ([maxPrice?.let { ... \}]), and the clause [where] receives the entire list ([where(conditions)], an overhead introduced with Jakarta Persistence 3.2), with the conditions combined by [and]. The first call produces a query with one condition; the second produces a query with two conditions. Note that with the API Criteria, Hibernate passes the values ([150], [d%]) as parameters in the order SQL: the template ["$it%"] on line 80 constructs the value for the parameter (the pattern for [like]), not the query text;
  • Lines 57–64: the aggregates [count] and [sum].

11.7.2. The Static Metamodel

The weakness of the queries in the first example is that attributes are designated by strings ([a.get<Int>("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. In Java, 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 Hibernate’s annotation processor. This is the recommended approach in a Java project that uses the API Criteria.

In Kotlin, it’s not quite that simple. The Hibernate annotation processor is a Java annotation processor: it is executed by [javac] on Java source code, but our entities are compiled by the Kotlin compiler. The Kotlin tool for this type of code generation is KSP (Kotlin Symbol Processing), but Hibernate does not provide a KSP processor. That leaves kapt (“Kotlin annotation processing tool”), which runs Java processors on “stubs ” generated from Kotlin code; however, kapt is in maintenance mode at JetBrains (which recommends KSP), it slows down compilation, and the kapt + Hibernate Processor combination is not a configuration documented by Hibernate. Therefore, we do not use it. Kotlin’s property references provide much of the benefit without any tools: with the program’s extension function, a typo is detected at compile time. Here’s what the compiler says for [a.get(Article::stock)], compiled into a test file:

1
2
3
Typo.kt:9:50: error: unresolved reference 'stock'.
fun condition(a: Path<Article>) = a.get(Article::stock)
                                                 ^^^^^

(The compiler also displays, beforehand, the list of candidate [get] methods, none of which are suitable.) However, the check is not comprehensive: a Kotlin property that is not a persistent attribute (a computed property, for example) would be accepted by the compiler but rejected by Hibernate at runtime.

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.java)]) 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: properties that reference another entity or a collection of entities. The [relations] subproject uses a [hbshop] database that describes a small store with six entities. Since it defines entities, its [build.gradle.kts] applies the [jpa] and [allopen] plugins, just like the one in the [articles] library; it depends directly on the [tools] library (the SQL log), Hibernate, and the MySQL driver:

// relationships between entities: the [hbshop] database
plugins {
    kotlin("jvm")
    // the parameterless constructor of the entities ("no-arg" plugin configured for JPA)
    kotlin("plugin.jpa")
    // entities and properties made open (non-final): Hibernate can derive proxies from them
    kotlin("plugin.allopen")
    application
}

repositories {
    mavenCentral()
}

dependencies {
    // the compact SQL log (and Logback)
    implementation(project(":tools"))
    // Hibernate and the MySQL JDBC driver
    implementation("org.hibernate.orm:hibernate-core:7.4.5.Final")
    runtimeOnly("com.mysql:mysql-connector-j:9.7.0")
}

// the classes annotated this way are opened by the [allopen] plugin
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the program run by [gradlew -q :relations:run]
    mainClass = "relations.MainKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}

A category groups products together:

package relations

import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.OneToMany
import jakarta.persistence.Table

// a category groups several products (one-to-many relationship)
@Entity
@Table(name = "categories")
class Category(
    @Column(nullable = false, length = 30)
    var name: String,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    // the products of the category: the inverse side of the relationship, described by Product.category
    @OneToMany(mappedBy = "category", cascade = [CascadeType.PERSIST])
    val products: MutableList<Product> = mutableListOf()

    // adding a product: the two sides of the relationship are updated
    fun add(product: Product) {
        products += product
        product.category = this
    }
}

A product belongs to a category and has tags:

package relations

import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.ForeignKey
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.JoinTable
import jakarta.persistence.ManyToMany
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import java.math.BigDecimal

// a product belongs to a category and carries several tags
@Entity
@Table(name = "products")
class Product(
    @Column(nullable = false, length = 30)
    var name: String,
    @Column(nullable = false, precision = 10, scale = 2)
    var price: BigDecimal,
    vararg tags: Tag,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    // many-to-one relationship: the foreign key [category_id] of the [products] table
    // [lateinit]: the category is filled in after construction, by Category.add
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "category_id", foreignKey = ForeignKey(name = "fk_products_category"))
    lateinit var category: Category
        internal set

    // 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"),
    )
    val tags: MutableSet<Tag> = mutableSetOf()

    init {
        // only the owning side of the relationship is filled in: it is the one Hibernate saves
        this.tags += tags
    }
}

A tag is assigned to multiple products:

package relations

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.ManyToMany
import jakarta.persistence.Table

// a tag (promo, new...) is carried by several products
@Entity
@Table(name = "tags")
class Tag(
    @Column(nullable = false, length = 20)
    var label: String,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    // the inverse side of the relationship described by Product.tags
    @ManyToMany(mappedBy = "tags")
    val products: MutableSet<Product> = mutableSetOf()
}

A customer places orders:

package relations

import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.OneToMany
import jakarta.persistence.Table

// a client places orders
@Entity
@Table(name = "clients")
class Client(
    @Column(nullable = false, length = 30)
    var name: String,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    @OneToMany(mappedBy = "client", cascade = [CascadeType.PERSIST])
    val orders: MutableList<CustomerOrder> = mutableListOf()

    // adding an order: the two sides of the relationship are updated
    fun add(order: CustomerOrder) {
        orders += order
        order.client = this
    }
}

An order belongs to a customer and contains line items:

package relations

import jakarta.persistence.CascadeType
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.Table
import java.time.LocalDate

// an order of a client, made of lines
@Entity
@Table(name = "orders")
class CustomerOrder(
    var date: LocalDate,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "client_id")
    lateinit var client: Client
        internal set

    @OneToMany(mappedBy = "order", cascade = [CascadeType.PERSIST])
    val lines: MutableList<OrderLine> = mutableListOf()

    // adding a line: the two sides of the relationship are updated
    fun add(line: OrderLine) {
        lines += line
        line.order = this
    }
}

An order line has an ID, a quantity, and two many-to-one associations—one to its order and one to its product:

package relations

import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table

// an order line: a quantity of a product
@Entity
@Table(name = "order_lines")
class OrderLine(
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id")
    var product: Product,
    var quantity: Int,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id")
    lateinit var order: CustomerOrder
        internal set
}

It contains three types of associations:

  • a many-to-one relationship ([@ManyToOne]): a product belongs to a category (the property [category] of [Product]). In the database, this is represented by a foreign key, the column [category_id] in the table [products], where [@JoinColumn] specifies the name and the constraint name ([fk_products_category]). [optional = false] makes the relationship mandatory: the column will be [NOT NULL]. Note the syntax for nested annotations in Kotlin: an annotation passed as an argument to another is written without [@] ([foreignKey = ForeignKey(name = ...)]), and an array of annotations is written in square brackets ([joinColumns = [JoinColumn(name = "product_id")]]), such as the array [cascade = [CascadeType.PERSIST]];
  • the inverse, one-to-many relationship ([@OneToMany]): a category contains multiple products (the property [products] of [Category]). It does not create anything in the database: [mappedBy = "category"] indicates that it is described by the property [category] of the entity [Product]. [Product.category] is said to be the owner side of the relationship, and [Category.products] its inverse side: Hibernate stores the value of the owner side. However, both sides must be kept consistent in memory: this is the role of the function [add] of [Category], which adds the product to the list ([products += product], the [+=] operator for a mutable collection, Chapter 5) and sets its category;
  • a many-to-many relationship ([@ManyToMany]): a product has multiple tags, and a tag is associated with multiple products. It is stored in an association table, [products_tags], for which [@JoinTable] specifies the name, the names of the two columns, and the names of the two foreign keys (the property [tags] of [Product]). The reverse side is [Tag.products] ([mappedBy = "tags"]). For a many-to-many relationship, a set ([MutableSet]) is preferred over a list ([MutableList]): with a list, Hibernate deletes and re-inserts all rows in the association table every time the collection is modified;
  • [fetch = FetchType.LAZY] requests lazy loading of a product’s category: it will only be read when 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.

The transition to Kotlin warrants several comments:

  • Collections are declared using Kotlin's mutable interfaces, [MutableList] and [MutableSet], which are initialized by [mutableListOf()] and [mutableSetOf()]. This choice is mandatory: Hibernate replaces the collection of an entity it reads with its own collection (a [PersistentBag] or a [PersistentSet]), which is a mutable [java.util.List] or [java.util.Set], and which can be loaded on demand. A read-only [List] from Kotlin would be acceptable to the compiler, but it would not accurately represent an object that Hibernate and the [add] function modify. Properties are [val]: we modify the contents of the collection, never the reference;
  • a relationship to a single entity, which is required, is declared as [lateinit var category: Category] (in [Product]; the same applies to [CustomerOrder.client] and [OrderLine.order]). The type is not nullable, because a registered product always has a category; however, at the time of its creation, it does not yet have one: [Category.add] will populate it. [lateinit] expresses exactly that: the property will be initialized later, and reading it before then would throw a [UninitializedPropertyAccessException], which is more descriptive than a [NullPointerException]. The Java course had relied on [null] up until then. We could also have written [var category: Category? = null], at the cost of having to use [?.] or [!!] each time;
  • the modifier [internal set] placed under these properties reserves the modification of a product’s category (of an order’s customer, of an order’s line item) for the module—that is, for the Gradle subproject [relations]: only the function [add] uses it. In Java, the method [setCategory] was visible only within the package; Kotlin does not have “package” visibility, and [internal] is the closest equivalent;
  • A product’s tags are passed to its constructor via a [vararg] parameter, which is not a property (there is no [val] or [var]): it is used only in the block [init], which adds them to the set. In this block, [tags] refers to the parameter and [this.tags] to the property; the block [init] is placed after the property declaration, because initializations are executed in the order they appear in the text. The operator [+=] adds all elements of an array to a mutable collection;
  • the parameterless constructor added by the [jpa] plugin (the one Hibernate uses to create the entities it reads) does not execute property initializations: the [tags] collection for a product read by Hibernate is therefore not created by [mutableSetOf()]. This is not a problem, because Hibernate itself populates all associations for the entities it reads using its lazy-loaded collections;
  • entities remain ordinary classes, not data classes. Relationships provide yet another reason for this: the functions [toString], [equals], and [hashCode] generated for a data class all use the properties of its primary constructor; if they included associations, they would iterate over the lazy collections (triggering queries or a [LazyInitializationException] outside the session), and two entities that reference each other would call each other indefinitely ([StackOverflowError]);
  • Finally, the [allopen] plugin is essential here: for a lazy relationship to a single entity, Hibernate places a proxy—an object of a subclass of the entity generated at runtime—in the property, which requires that the class and its accessors be non-final. We’ll see this proxy in action in the program.

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

package relations

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

// the [hbshop] database: Hibernate configuration and first data set
object Shop {

    // a SessionFactory for the [hbshop] database and its six entities
    fun createSessionFactory(): SessionFactory =
        HibernatePersistenceConfiguration("shop")
            .managedClasses(
                Category::class.java, Product::class.java, Tag::class.java,
                Client::class.java, CustomerOrder::class.java, OrderLine::class.java,
            )
            .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
    fun initialize(sessionFactory: SessionFactory) = SqlLog.withoutLog {
        sessionFactory.schemaManager.drop(false)
        sessionFactory.schemaManager.create(false)
        sessionFactory.inTransaction { session ->
            // two tags
            val promo = Tag("promo")
            val novelty = Tag("new")
            // three categories and their products: a graph of objects
            val cycles = Category("cycles")
            cycles.add(Product("bike", BigDecimal(500), promo))
            val pump = Product("pump", BigDecimal(10))
            cycles.add(pump)
            val archery = Category("archery")
            val bow = Product("bow", BigDecimal(600), novelty)
            archery.add(bow)
            archery.add(Product("arrows - set of 6", BigDecimal(100), promo, novelty))
            val diving = 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
            val order = CustomerOrder(LocalDate.of(2026, 9, 28))
            order.add(OrderLine(pump, 2))
            order.add(OrderLine(bow, 1))
            val martin = Client("Martin")
            martin.add(order)
            session.persist(martin)
        }
    }
}
  • Line 11: Like [BaseArticles], a [object] replaces the Java utility class;
  • lines 16–18: the six entities, declared by [managedClasses];
  • line 26: the function is an expression: the result of [SqlLog.withoutLog], to which the processing to be executed without a log (SQL) is passed;
  • lines 31–42: an object graph: three categories, their products, and the products’ tags;
  • lines 44–46: 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 48–53: 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
  • The list of tables: one table per entity, and the association table [products_tags];
  • the table [products]: its foreign key [category_id] references the table [categories]. It is [NOT NULL] ([optional = false]). MySQL creates an index on each foreign key (row [KEY]). Unlike other ORM entries, Hibernate does not require cascade deletion in the database ([ON DELETE CASCADE]): this can be achieved using the [@OnDelete(action = OnDeleteAction.CASCADE)] annotation;
  • the association table has two columns—foreign keys to the two linked tables—which together form its primary key.

The schema is exactly the same as the one used in the Java course: the annotations, written in Kotlin, are identical.

11.8.2. The program

package relations

import org.hibernate.LazyInitializationException
import org.hibernate.Session
import java.math.BigDecimal
import java.time.LocalDate

// relationships between entities: lazy loading, join fetch, entity graph, joins

// the results of the projection queries
data class ProductCategory(val product: String, val category: String)

data class OrderAmount(val id: Int, val client: String, val amount: BigDecimal)

data class CategoryStatistics(val category: String, val count: Long, val averagePrice: Double)

fun main() {
    Shop.createSessionFactory().use { sessionFactory ->
        // 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
        println("--- addition of a client, an order and its two lines")
        sessionFactory.inTransaction { session ->
            val bike = session.product("bike")
            val arrows = session.product("arrows - set of 6")
            val order = CustomerOrder(LocalDate.of(2026, 10, 1))
            order.add(OrderLine(bike, 1))
            order.add(OrderLine(arrows, 3))
            val client = Client("Dupont")
            client.add(order)
            session.persist(client)
            println("client no. ${client.id}, order no. ${order.id}")
        }

        // 3 - lazy loading: the relationships are read on demand
        println("--- lazy loading")
        sessionFactory.inSession { session ->
            val orders = session
                .createSelectionQuery("from CustomerOrder order by id", CustomerOrder::class.java)
                .resultList
            for (order in orders) {
                // the client and the lines are only read here, when first used
                println("order no. ${order.id} : client=${order.client.name}, " +
                        "${order.lines.size} line(s)")
            }
            // the [client] property contains a proxy: an object of a subclass of Client
            println("class of the client: ${orders[0].client.javaClass.name}")
        }

        // 4 - a relationship that is not loaded is no longer accessible once the session is closed
        println("--- after the session is closed")
        val detachedOrder = sessionFactory.fromSession { it.find(CustomerOrder::class.java, 1) }
        try {
            println("${detachedOrder.lines.size} line(s)")
        } catch (e: LazyInitializationException) {
            println("${e.javaClass.simpleName} : ${e.message}")
        }

        // 5 - join fetch: the orders, their client, their lines and their products in one query
        println("--- join fetch")
        sessionFactory.inSession { session ->
            val 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.java
            ).resultList
            for (order in orders) {
                println("order no. ${order.id} of ${order.date} for ${order.client.name}")
                for (line in order.lines) {
                    val product = line.product
                    println("  %d x %s at %.2f euros".format(line.quantity, product.name, product.price))
                }
            }
        }

        // 6 - an entity graph: what must be loaded with the entity, described outside the query
        println("--- entity graph")
        val category = sessionFactory.fromSession { session ->
            val graph = session.createEntityGraph(Category::class.java)
            graph.addAttributeNodes(Category::products.name)
            session.find(graph, 2)
        }
        // the session is closed, but the products have been loaded
        println("${category.name} : ${category.products.joinToString(", ") { it.name }}")

        // 7 - the many-to-many relationship in a query
        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 = :label order by p.name""", ProductCategory::class.java
            )
                .setParameter("label", "promo")
                .resultList
                .forEach { (product, category) -> println("$product ($category)") }
        }

        // 8 - a computation across the relationships, done by the DBMS
        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.java
            )
                .resultList
                .forEach { (id, client, amount) ->
                    println("order no. %d (%s) : %.2f euros".format(id, client, amount))
                }
        }

        // 9 - a grouping: number of products and average price per category
        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.java
            )
                .resultList
                .forEach { (category, count, averagePrice) ->
                    println("%s : %d product(s), average price %.2f euros".format(category, count, averagePrice))
                }
        }
    }
}

// the product with the given name: an extension function of the session
fun Session.product(name: String): Product =
    createSelectionQuery("from Product where name = :name", Product::class.java)
        .setParameter("name", name)
        .singleResult

Results:

> gradlew -q :relations:run
--- 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)
class of the client: relations.Client$HibernateProxy
--- 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=?
    SQL> order by p1_0.name
    SQL> -- parameter 1 = promo
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 11–15: The data classes that will receive the projection results. Their types correspond to those returned by the DBMS: [Long] for [count], [Double] for [avg];
  • lines 135–138: the product with the given name, read by an extension function of the Hibernate interface [Session]: inside it, [createSelectionQuery] is called on the session itself ([this], implicit). It is called as a session method: [session.product("bike")] (line 25), where the Java code previously defined a static method [product(session, "bike")];
  • lines 24–33: a customer, their order, and its two lines are persisted with a single call to [persist]. SQL shows the order of insertions: first the customer, then the order—whose foreign key [client_id] receives the generated key from the customer (2)—and finally the lines, whose foreign key [order_id] receives the key from the order. [inTransaction] receives a Kotlin lambda where Hibernate expects a Java functional interface ([Consumer<Session>]): this is the conversion SAM from Chapter 7;
  • lines 39–45: lazy loading. The query reads only the orders: the customer and the lines for each order are read only when the program uses them, via additional queries (two for the first order, two for the second). For the customer, Hibernate has placed a proxy in the [client] property—an object of a subclass of [Client] generated by Hibernate (using Byte Buddy)—which contains only the identifier and reads the client on the first call to an accessor ([order.client.name], i.e., [getName()]). Line 48 shows this: the object’s class is [relations.Client$HibernateProxy]. This is why an entity must not be a final class, nor its accessors final methods: in Kotlin, where classes and members are final by default, it is the [allopen] plugin that makes them non-final. For the rows, the list is a Hibernate collection, loaded on first use ([size]);
  • Lines 53–57: The command is read by a session that is immediately closed ([fromSession], which returns the result of the lambda). These lines were not loaded, and once the session is closed, they can no longer be loaded: accessing the collection triggers a [LazyInitializationException], one of the most common exceptions in applications that use Hibernate. Nothing in the property’s [MutableList<OrderLine>] type signals this risk to the Kotlin compiler: type safety ends where on-demand loading begins. You must load what you’ll need while the session is open;
  • lines 63–69: 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. The output shows both orders and their lines. The join with the rows produces one SQL row per order line; Hibernate derives a single order for each ID: the rendered list does indeed contain two orders. The query text, spanning multiple lines, is a raw string;
  • lines 82–88: an “entity graph” (Jakarta Persistence) describes, outside of any query, the associations to be loaded with an entity: here, the products in the category, identified by the property reference [Category::products.name] rather than by the string ["products"]. [session.find(graph, 2)] (Jakarta Persistence 3.2) reads category 2 along with its products in a single query (an outer join, [left join]). Once the session is closed, the products remain accessible. To load an association later, while the session is open, use [Hibernate.initialize(category.products)]. Note the call to [addAttributeNodes] (plural) rather than [addAttributeNode]: the latter, which is generic, returns a *[AttributeNode<Y>]*, and Kotlin cannot infer the type *[Y]* when the result is not used (“cannot infer type for type parameter 'Y'”);
  • Lines 95–100: 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), and [p.category.name] is translated into an implicit join with the category table. The label text is passed via a named parameter (the Java course wrote it as a literal in the query); each result is unpacked into the lambda’s parameters: [{ (product, category) -> ... \}];
  • lines 108–114: a calculation involving two relations—the amount of each order—entirely performed by the DBMS: joins, [sum] and [group by];
  • lines 123–128: a grouping. [group by p.category.name] groups products by category name; for each group, the number of products ([count], of type [Long]) and the average price ([avg], of type [Double]) are calculated by the DBMS. The category [diving], which has no product, 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 affects 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 line items). 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.client.name] doesn’t look like a query, and Kotlin’s property syntax hides it even better than the Java call [getClient().getName()]. The solutions are those shown in the example: [join fetch], an entity graph, or better yet, a projection onto a data class that retrieves only the necessary 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 SQL log 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 were 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 schema migrations: it can create a schema, validate it, and make limited updates to it. The standard practice, as described in JVM (in both Java and Kotlin), is to delegate migrations to versioned SQL scripts, applied by a dedicated tool—most commonly 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 is used by the program below) 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 the tables
[validate]
[VALIDATE]
Check that the tables and columns of the entities exist, with 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 [update] option 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 property 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 an SQL script, numbered sequentially: [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] subproject implements a small versioned script runner, modeled after Flyway; we will then introduce Flyway itself.

11.9.4. The [clients] subproject

The [clients] subproject uses the [hbclients] database. It has two programs: [clients.MainKt], which runs by default, and [clients.GenerateScriptKt], which is specified as needed by the [main] property, as in the previous chapters. Its [build.gradle.kts] is the same as that of the [relations] subproject, with one exception:

1
2
3
4
5
application {
    // the program run by [gradlew -q :clients:run]: clients.MainKt by default,
    // or the one given by the [main] property: gradlew -q :clients:run -Pmain=clients.GenerateScriptKt
    mainClass = providers.gradleProperty("main").orElse("clients.MainKt")
}

The entity is a customer:

package clients

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table

// a client
@Entity
@Table(name = "clients")
class Client(
    @Column(nullable = false, length = 50)
    var name: String,
    @Column(nullable = false, length = 100)
    var email: String,
    // version 2: the phone, optional (type String? and a column that accepts NULL)
    @Column(length = 20)
    var phone: String? = null,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    override fun toString() = "[$id, $name, $email, ${phone ?: "no phone"}]"
}

In the first version of the application, the customer does not have a phone number: lines 18–20 do not yet exist. We will add them in the second version. The phone number is optional, which Kotlin expresses using the nullable type [String?] and the default value [null] (so we can create a customer without a phone number: [Client("Durand", "durand@example.com")]). Hibernate, however, does not recognize Kotlin types: it is the absence of ***[nullable = false] in *[@Column] that makes the column optional (conversely, a non-nullable Kotlin [String] only produces a [NOT NULL] column if explicitly requested, as is the case with [name] and [email]). The [toString] function (line 27) uses the Elvis operator to display the absence of a phone number.

The scripts are located in the [src/main/resources/db/migration] folder of the subproject, which Gradle copies to the classpath (into the [build/resources/main] folder). These are the scripts from the Java course: an SQL script does not depend on the application’s programming language. The first one 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 applies the scripts using JDBC (Chapter 10), independently of Hibernate:

package clients

import java.nio.file.Path
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
import kotlin.io.path.readLines

// 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
class Migrations(
    private val url: String,
    private val user: String,
    private val password: String,
) {
    // a migration script
    data class Script(val version: Int, val description: String, val path: Path) {
        override fun toString() = "V$version ($description)"
    }

    private companion object {
        // the name of a script: V1__create_clients.sql
        val NAME = Regex("""V(\d+)__(\w+)\.sql""")

        // the history table
        const val 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"""
    }

    // a connection to the database
    private fun connection(): Connection = DriverManager.getConnection(url, user, password)

    // the scripts of the [db/migration] folder of the classpath, in version order
    fun scripts(): List<Script> {
        val folder = Migrations::class.java.getResource("/db/migration") ?: return emptyList()
        return Path.of(folder.toURI()).listDirectoryEntries()
            .mapNotNull { file ->
                // the files whose name does not follow the convention are ignored (null)
                NAME.matchEntire(file.name)?.destructured?.let { (version, description) ->
                    Script(version.toInt(), description.replace('_', ' '), file)
                }
            }
            .sortedBy { it.version }
    }

    // the versions already applied to the database (the history table is created if needed)
    fun appliedVersions(): Set<Int> = connection().use { cnx ->
        cnx.createStatement().use { statement ->
            statement.execute(CREATE_HISTORY)
            statement.executeQuery("select version from migration_history").use { rows ->
                sortedSetOf<Int>().apply {
                    while (rows.next()) add(rows.getInt(1))
                }
            }
        }
    }

    // the scripts that remain to be applied
    fun pending(): List<Script> {
        val applied = appliedVersions()
        return scripts().filter { it.version !in applied }
    }

    // application of the pending scripts, up to version [target] included; the applied scripts are returned
    fun migrate(target: Int = Int.MAX_VALUE): List<Script> =
        pending().takeWhile { it.version <= target }.onEach { applyScript(it) }

    // application of a script and addition of its version to the history, in a transaction
    private fun applyScript(script: Script) {
        // the statements of the script, separated by semicolons, without the comment lines
        val statements = script.path.readLines()
            .filterNot { it.trim().startsWith("--") }
            .joinToString("\n")
            .split(";")
            .filter { it.isNotBlank() }
        connection().use { cnx ->
            cnx.autoCommit = false
            try {
                cnx.createStatement().use { statement -> statements.forEach { statement.execute(it) } }
                cnx.prepareStatement("insert into migration_history(version, description) values (?, ?)")
                    .use { history ->
                        history.setInt(1, script.version)
                        history.setString(2, script.description)
                        history.executeUpdate()
                    }
                cnx.commit()
            } catch (e: SQLException) {
                // beware: with MySQL, a schema modification statement cannot be rolled back
                cnx.rollback()
                throw e
            }
        }
    }
}
  • lines 14–17: the database is described by the private properties of the primary constructor;
  • lines 20–21: a script is described by a nested data class, [Migrations.Script]: its version, description, and path; its [toString] method is overridden for display;
  • lines 24–34: the class constants, in a private companion object. Line 26: a script’s name follows the Flyway convention: [V]—the version number, two underscores, and a description. The regular expression is written as a raw string, where the backslash does not need to be doubled (["""V(\d+)__(\w+)\.sql"""]; in Java: ["V(\\d+)__(\\w+)\\.sql"]). The history table (one row per applied version, with its description and the date it was applied) is created by a command written as a raw string, a constant [const val];
  • lines 41–50: the list of scripts. [getResource("/db/migration")] returns the URL from the directory in the classpath, or [null] if it does not exist: the Elvis operator then immediately returns an empty list ([?: return emptyList()]). The directory is listed by the [listDirectoryEntries] extension function of [kotlin.io.path] (Chapter 5). Each file is checked against the regular expression by [matchEntire], which returns [null] if the name does not match: [mapNotNull] filters out these files, and the expression’s groups are retrieved via destructuring ([destructured], Chapter 5). This code assumes that the directory is a real directory (which is the case when the program is run by Gradle from the [build/resources/main] directory); Flyway can also read scripts from a [.jar] archive. The scripts are sorted by version number ([sortedBy]): alphabetical sorting would place [V10] before [V2];
  • Lines 54–59: The versions that have already been applied, retrieved from the history table, which is created as needed ([create table if not exists]). The three JDBC resources (connection, statement, results) are closed by three nested [use] resources. The function returns a sorted set ([sortedSetOf], a Java [TreeSet]), populated by [apply];
  • lines 66–68: the remaining scripts to be applied: the [!in] operator checks for the absence of a version in the set;
  • lines 72–73: application of the pending scripts, up to a given version (all by default: [target: Int = Int.MAX_VALUE]). [takeWhile] retains the scripts as long as their version does not exceed the target; [onEach] applies each of them and returns the list itself: the function fits into a single expression, whereas Java would have written a loop using [break];
  • lines 76–97: applying a script: its commands (comment lines removed by [filterNot], the text split at semicolons), then adding its version to the history table, within a transaction ([cnx.autoCommit = false], the Kotlin property corresponding to [setAutoCommit]). One caveat, noted in the comment on line 95: 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 is advisable to use short scripts, each performing a single operation.

Note that no function in the class declares the exceptions it may throw: the Java version had to specify [throws SQLException, IOException, URISyntaxException] on most of its methods, as well as on the [main] method that calls them. Kotlin does not have controlled exceptions (Chapter 3): a [SQLException] simply propagates up to whoever wants to handle it.

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

package clients

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

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

fun main(args: Array<String>) {
    // the targeted version: the last one by default, or the one passed as an argument
    val target = args.firstOrNull()?.toInt() ?: Int.MAX_VALUE

    // the state of the migrations
    val migrations = Migrations(URL, "root", "")
    println("applied versions: ${migrations.appliedVersions()}")
    println("pending migrations: ${migrations.pending()}")

    // application of the pending migrations
    for (script in migrations.migrate(target)) {
        println("migration applied: $script")
    }

    // Hibernate checks that the database schema matches the entities (validate)
    try {
        HibernatePersistenceConfiguration("clients")
            .managedClass(Client::class.java)
            .jdbcUrl(URL)
            .jdbcCredentials("root", "")
            .property(AvailableSettings.FORMAT_SQL, true)
            .schemaToolingAction(Action.VALIDATE)
            .createEntityManagerFactory()
            .use { sessionFactory ->
                println("the database schema matches the entities")
                // the new column is usable
                sessionFactory.inTransaction { session ->
                    session.persist(Client("Dupont", "dupont@example.com", "01 23 45 67 89"))
                }
                sessionFactory.inSession { session ->
                    session.createSelectionQuery("from Client", Client::class.java)
                        .resultList
                        .forEach(::println)
                }
            }
    } catch (e: PersistenceException) {
        // the validation error is the cause of the exception
        val cause = e.cause ?: e
        println("${cause.javaClass.simpleName} : ${cause.message}")
    }
}
  • line 9: the database’s URL, a top-level constant in the [clients] package, which the second program will also use;
  • line 13: You can specify, as an argument, the version up to which to apply the migrations; by default, all are applied. [args.firstOrNull()?.toInt() ?: Int.MAX_VALUE] combines the safe call and the Elvis operator;
  • lines 16–22: the status of the migrations, followed by their application;
  • lines 27–34: [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 with a [PersistenceException], the cause of which is the validation error (lines 46–49). [e.cause ?: e] captures the cause, or the exception itself if it has none;
  • lines 37–43: the new column is used.

11.9.5. Generating 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 program [GenerateScript.kt] does:

package clients

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

// generation by Hibernate of the SQL script that creates (create) or updates (update) the database schema
fun main(args: Array<String>) {
    // the action: create (by default) or update
    val action = args.firstOrNull() ?: "create"
    val script = StringWriter()
    // a SessionFactory that writes the script without executing it; it is only created for that:
    // it is closed immediately
    HibernatePersistenceConfiguration("clients")
        .managedClass(Client::class.java)
        .jdbcUrl(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()
        .close()
    // the script, without the 4-space indentation of its lines
    script.toString().trim().lines()
        .map { it.removePrefix("    ").trimEnd() }
        .forEach(::println)
}
  • line 10: the action, passed as an argument: [create] or [update];
  • lines 14–23: [SessionFactory] is created solely to generate the script: it is closed immediately after creation, without even being stored in a variable (the Java course declared it in a [try] statement with resources, using the anonymous Java variable [_] from Java 22);
  • lines 18–21: the script generation properties, already encountered in the subproject [model]. This time, Hibernate connects to the database: for the action [update], it needs to know its schema;
  • lines 25–27: the script, with the 4-space indentation removed from its lines by [removePrefix], which only removes the prefix if it is present.

Note that the two programs in the subproject each have a top-level [main] function in the same package: this is permitted for the [main] function, as each is compiled into its own file class ([clients.MainKt], [clients.GenerateScriptKt]).

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

1
2
3
4
5
6
7
8
> gradlew -q :clients:run -Pmain=clients.GenerateScriptKt
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 produced, in the same column order, the script [V1__create_clients.sql].

11.9.6. Apply the migrations

Let’s assume the database does not yet exist. We apply only the first migration, passing version 1 as an argument (option [--args] of task [run]):

1
2
3
4
5
> gradlew -q :clients:run --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]
  • No version is applied; two scripts are pending (the Kotlin list is displayed the same way as the Java list, in square brackets). The history table was created by function [appliedVersions], and the database [hbclients] by MySQL ([createDatabaseIfNotExist=true]);
  • the script [V1] is applied;
  • 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
> gradlew -q :clients:run -Pmain=clients.GenerateScriptKt --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:

> gradlew -q :clients:run
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]
  • Version 1 is applied, version 2 is pending, and it is applied;
  • Validation succeeds;
  • the new column is ready for use: the customer is saved with their phone number, then retrieved.

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-04 10:55:57 |
|       2 | add phone      | 2026-10-04 10:56:18 |
+---------+----------------+---------------------+

Running the program again does not apply any changes: both versions appear in the history (however, it does add a second customer named Dupont, which we are not showing):

1
2
3
4
> gradlew -q :clients:run
applied versions: [1, 2]
pending migrations: []
the database schema matches the entities

11.9.7. Flyway

Flyway is the most widely used migration tool for JVM (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 is used, and pending scripts are applied 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 the application’s language (Java or Kotlin classes); a lock that prevents two instances of the application from migrating the database at the same time, and a Gradle plugin (tasks [flywayMigrate], [flywayInfo]).

To use it in the [clients] subproject, simply add the dependencies [org.flywaydb:flyway-core] and [org.flywaydb:flyway-mysql] (in their latest versions) to [build.gradle.kts], keep both 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 XML, YAML, or SQL files (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. Chapters 8 and 9 presented two ways to avoid wasting threads during these waits: JDK virtual threads and Kotlin coroutines. Hibernate’s API, like the JDBC it uses, is blocking: [getResultList] (the [resultList] property in Kotlin) does not yield control until the lines have been read. With virtual threads, this is no longer a drawback: a virtual thread blocked on I/O frees up the system thread that was executing it, and you can create thousands of them. So you write ordinary blocking code, executed by virtual threads. With coroutines, we similarly entrust this blocking code to a dispatcher designed for I/O, [Dispatchers.IO] (Chapter 9), using [withContext]: the coroutine waiting for the result is suspended, without blocking its thread.

One rule must be followed in both cases: a session is not thread-safe. It is never shared among multiple threads or multiple coroutines: each thread, each task, and each coroutine opens its own session with [SessionFactory], which is itself shared. With coroutines, this rule requires special attention: a coroutine may resume execution after a suspension on a different thread than the one where it started; a session opened before a suspended call and used after it would therefore switch threads. The simplest approach is to open and close the session within the [withContext] block, which contains no suspension points. Hibernate does not always detect concurrent use of the same session; the resulting errors are unpredictable.

The [async] subproject enforces this rule. In addition to the [articles] library, it depends on kotlinx-coroutines:

// Hibernate, virtual threads and coroutines
plugins {
    kotlin("jvm")
    // no [jpa] or [allopen] plugin: the module does not define any entity (Article is compiled in [articles])
    application
}

repositories {
    mavenCentral()
}

dependencies {
    // the [Article] entity and the [hbarticles] database (Hibernate, MySQL and the SQL log included)
    implementation(project(":articles"))
    // coroutines (chapter 9)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the program run by [gradlew -q :async:run]
    mainClass = "async.MainKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • line 14: the [Article] entity and the [hbarticles] database, as in the previous examples;
  • line 16: the coroutines library (Chapter 9);
  • lines 32–35: the programs launched by Gradle write their output to UTF-8, so that accented characters display correctly on Windows (Chapter 2).

The program:

package async

import articles.Article
import articles.BaseArticles
import jakarta.persistence.QueryTimeoutException
import java.math.BigDecimal
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.hibernate.SessionFactory

// Hibernate (a blocking API) with virtual threads, then with coroutines
fun main() {
    BaseArticles.createSessionFactory().use { sessionFactory ->
        // 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 one with its session
        println("--- two queries in parallel")
        Executors.newVirtualThreadPerTaskExecutor().use { executor ->
            val lowPrices = executor.submit<List<Article>> {
                sessionFactory.fromSession { s ->
                    s.createSelectionQuery("from Article where price < 200 order by price", Article::class.java)
                        .resultList
                }
            }
            val hql = "select count(*) from Article where currentStock < minimumStock"
            val outOfStock = executor.submit<Long> {
                sessionFactory.fromSession { s ->
                    s.createSelectionQuery(hql, Long::class.java).singleResult
                }
            }
            // get() waits for the end of each task
            lowPrices.get().forEach { println(it) }
            println("${outOfStock.get()} article(s) out of stock")
        }

        // 2 - a transaction executed by a virtual thread, followed by a processing of its result
        println("--- CompletableFuture")
        Executors.newVirtualThreadPerTaskExecutor().use { executor ->
            val processing = CompletableFuture
                .supplyAsync({
                    sessionFactory.fromTransaction { session ->
                        session.createSelectionQuery("from Article where name = 'bow'", Article::class.java)
                            .singleResult
                            .apply { price = BigDecimal.valueOf(550) }
                    }
                }, executor)
                .thenAccept { bow -> println("new price of the bow: ${bow.price}") }
            processing.join()
        }

        // 3 - coroutines: each one executes a suspending function that opens its own session
        println("--- coroutines")
        runBlocking {
            // two child coroutines of runBlocking, executed at the same time
            val cheap = async {
                countArticles(sessionFactory, "select count(*) from Article where price < 200")
            }
            val expensive = async {
                countArticles(sessionFactory, "select count(*) from Article where price >= 200")
            }
            // await() suspends the main coroutine until the result of each child coroutine
            println("${cheap.await()} article(s) under 200 euros")
            println("${expensive.await()} article(s) at 200 euros or more")
        }

        // 4 - a stream: the entities are processed as they are read
        println("--- getResultStream")
        sessionFactory.inSession { session ->
            session.createSelectionQuery("from Article where price >= 300", Article::class.java)
                .resultStream
                .use { articles -> articles.forEach { println("read: ${it.name}") } }
        }

        // 5 - a query that is too long is interrupted after the set timeout (in seconds)
        println("--- timeout exceeded")
        try {
            sessionFactory.inSession { session ->
                session.createNativeQuery("select sleep(5)", Int::class.java)
                    .setTimeout(1)
                    .singleResult
            }
        } catch (e: QueryTimeoutException) {
            println("${e::class.simpleName} : ${e.message}")
        }
    }
}

// a counting query executed in a session specific to the coroutine; the blocking work is
// handed to the [IO] dispatcher: the calling coroutine is suspended, its thread is not blocked
suspend fun countArticles(sessionFactory: SessionFactory, hql: String): Long =
    withContext(Dispatchers.IO) {
        sessionFactory.fromSession { session ->
            session.createSelectionQuery(hql, Long::class.java).singleResult
        }
    }

Results:

> gradlew -q :async:run
--- two queries in parallel
    SQL> select count(*)
    SQL> from articles a1_0
    SQL> where a1_0.currentStock<a1_0.minimumStock
    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
[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
--- coroutines
    SQL> select count(*)
    SQL> from articles a1_0
    SQL> where a1_0.price<200
    SQL> select count(*)
    SQL> from articles a1_0
    SQL> where a1_0.price>=200
3 article(s) under 200 euros
3 article(s) at 200 euros or more
--- 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 23: an executor that creates a virtual thread per task (Chapter 8). A [ExecutorService] is [AutoCloseable]: the function [use] closes it at the end of the block, and its closure waits for all tasks to finish;
  • Lines 24–33: Two tasks submitted to the executor. Each opens its own session ([fromSession]) and executes a query; both queries run simultaneously, on two connections from the pool. Their order in the log (lines 3–9 of the results) may therefore vary from one execution to another. The result type is specified in [submit] ([submit<List<Article>>]): otherwise, the lambda might be interpreted as a [Runnable]—which returns nothing—rather than as a [Callable];
  • Line 33: the type of the result of the count query. [Long::class.java] here refers to the Java primitive type [long]; Hibernate accepts it and returns the value as a [java.lang.Long], which Kotlin treats as a [Long];
  • lines 36–38: [get()] waits for each task to complete and returns its result (lines 10–13);
  • lines 44–53: 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 function [apply] modifies the read record and returns it: this is the value of the lambda, and thus the result of the transaction. The record returned by the transaction is detached (its session is closed): we can read its properties but not load its associations;
  • lines 58–68: the same idea using coroutines. [runBlocking] creates a main coroutine; [async] launches two child coroutines, which call the suspension function [countArticles]. Both requests run simultaneously (lines 30–35), and [await] suspends the main coroutine until each one returns (lines 36–37);
  • lines 95–96: the suspension function delegates the blocking work to the [Dispatchers.IO] dispatcher, whose threads are designed to wait for I/O operations. The session is opened and closed by [fromSession] within the [withContext] block: it is used by only one coroutine, and on a single thread. This is the approach to use when calling Hibernate from suspended code. One could also use a dispatcher built on virtual threads ([Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()], Chapter 9);
  • lines 73–76: the property [resultStream] (the method [getResultStream]) returns a Java stream: entities are processed as they are read, without forming a list (lines 42–44). A stream is [AutoCloseable]: [use] closes it, which releases the JDBC cursor. It could also be converted to a Kotlin sequence ([asSequence()], Chapter 5). This is useful for processing a large number of lines. With MySQL, note that by default the driver reads all lines 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 83–87: 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 46–48).

The duration of the last request warrants a note. In Chapter 9, we canceled coroutines ([job.cancel()], [withTimeout]) and tasks ([Future.cancel]). These cancellations would have no effect on the request here: canceling a coroutine is cooperative, and a coroutine blocked in the JDBC driver does not check whether it has been canceled; as for interrupting a thread blocked in the driver, it does not interrupt the request currently in progress on the server. A [withTimeout(1000) { countArticles(...) }] would indeed launch a [TimeoutCancellationException]... but only once the request is complete, after five seconds: the [withContext] block of [countArticles] does not yield control until it has finished its blocking operation. To limit the duration of a request, you must therefore address the JDBC driver via [setTimeout].

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; A [CompletionStage] is expected in a coroutine with the [await] extension function from kotlinx-coroutines. Virtual threads and the [Dispatchers.IO] dispatcher eliminate this complexity in most cases: this 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 operation is performed 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 the 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 SQL statement, and so on. These are uncaught exceptions, in both Java and Kotlin (which, as a reminder, does not have caught exceptions: Chapter 3); we only catch those that we know how to handle.

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

package transactions

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

// the two ways of managing a transaction: inTransaction, and a transaction managed by the code
fun main() {
    BaseArticles.createSessionFactory().use { sessionFactory ->
        // the [articles] table is recreated with its six articles
        BaseArticles.initialize(sessionFactory)

        // 1 - inTransaction: commit if everything succeeds, rollback in case of an exception
        println("--- two additions, the second of which violates the uniqueness of the name")
        try {
            sessionFactory.inTransaction { session ->
                session.persist(Article("racket", BigDecimal.valueOf(80), 5, 2))
                session.persist(Article("bike", BigDecimal.valueOf(450), 3, 1))
            }
        } catch (e: ConstraintViolationException) {
            println("${e::class.simpleName} : constraint [${e.constraintName}] violated")
        }
        display(sessionFactory, "racket")

        // 2 - a transaction managed by the code: several writes, committed or rolled back together
        println("--- transaction rolled back")
        sessionFactory.openSession().use { session ->
            val transaction = session.beginTransaction()
            try {
                // sale of two bikes: the UPDATE is sent by flush
                val bike = session
                    .createSelectionQuery("from Article where name = 'bike'", Article::class.java)
                    .singleResult
                bike.currentStock -= 2
                session.flush()
                // addition of an article whose name already exists: error
                session.persist(Article("pump", BigDecimal.valueOf(12), 5, 1))
                // commit of the whole (not reached here)
                transaction.commit()
            } catch (e: ConstraintViolationException) {
                println("${e::class.simpleName} : ${e.sqlException.message}")
                // 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
fun display(sessionFactory: SessionFactory, name: String) {
    val article = sessionFactory.fromSession { session ->
        session.createSelectionQuery("from Article where name = :name", Article::class.java)
            .setParameter("name", name)
            .singleResultOrNull
    }
    println("article [$name] in the database: ${article ?: "missing"}")
}

Results:

> gradlew -q :transactions:run
--- 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 18–20: 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 22–23: the exception is caught; the [constraintName] property (the [getConstraintName] method) returns the name of the violated constraint (line 18). This demonstrates the value of having named the constraint;
  • line 25: the racket, which had been successfully inserted, is not in the database (line 23): rolling back the transaction deleted it;
  • Lines 29–45: A transaction managed by the code, in a session opened by [openSession] and closed by [use] ([Session] is [AutoCloseable]). Two bicycles are sold ([bike.currentStock -= 2]: the operator [-=] is applied to a property [var] just as it is to a variable), and the change is sent to the DBMS via an explicit [flush] (lines 29–36); then an item is added whose name already exists: the insertion fails (line 43);
  • line 41: the commit is never reached;
  • lines 42–45: in the [catch], the transaction is rolled back (line 45 of the results). The [sqlException] property returns the original exception from the JDBC driver, and its message is that of MySQL (line 44);
  • line 49: the sale of bicycles, although sent to the DBMS by [flush], was canceled: the bicycle inventory is still 10 (line 50);
  • lines 54–60: a function that re-reads a record with a new session to view the actual state of the database rather than the entities from the current working session. The property [singleResultOrNull] sets [null] if the query finds nothing; the Elvis operator ([?:]) then displays “missing.”

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 if necessary with a new session. This is what [inTransaction] does, and what our code does when exiting the [use] 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 property annotated with [@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 via the commands [UPDATE] and [DELETE]. If the transaction does not modify any rows, it means another user has already accessed the data: Hibernate executes a [OptimisticLockException].

The subproject [concurrency] 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")
class Article(
    @Column(nullable = false, length = 30)
    var name: String,
    var currentStock: Int,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null
        protected set

    // the version number: incremented by Hibernate at each modification of the row
    @Version
    var version: Int = 0
        protected set
}
  • lines 15–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: its modifier is protected ([protected set]), just like that of the identifier; Hibernate, which accesses the field directly, is not affected by this. The initial value [0] is that of an article created by the application; for an article read from the database, the parameterless constructor added by the plugin [jpa] does not perform this initialization (as we have seen), but Hibernate immediately assigns the read value to the property.

Since the entity is defined in the subproject itself, its build file applies the [jpa] and [allopen] plugins, and depends directly on Hibernate and the [tools] library:

// optimistic concurrency: an entity with a version number, in its own [hbconcurrency] database
plugins {
    kotlin("jvm")
    // the module defines its own entity: the [jpa] and [allopen] plugins
    kotlin("plugin.jpa")
    kotlin("plugin.allopen")
    application
}

repositories {
    mavenCentral()
}

dependencies {
    // the compact SQL log (and Logback)
    implementation(project(":tools"))
    // Hibernate and the MySQL JDBC driver
    implementation("org.hibernate.orm:hibernate-core:7.4.5.Final")
    runtimeOnly("com.mysql:mysql-connector-j:9.7.0")
}

// the classes annotated this way are opened by the [allopen] plugin
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the program run by [gradlew -q :concurrency:run]
    mainClass = "concurrency.MainKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • lines 5–6: the two entity plugins, configured by the [allOpen] block (lines 23–26);
  • lines 16–19: the compact SQL log, Hibernate, and the JDBC driver for MySQL;
  • lines 42–45: the programs launched by Gradle write their output to UTF-8, so that accented characters display correctly on Windows (Chapter 2).

The program simulates the two users with two sessions:

package concurrency

import jakarta.persistence.OptimisticLockException
import org.hibernate.cfg.AvailableSettings
import org.hibernate.jpa.HibernatePersistenceConfiguration
import tools.SqlLog

// optimistic concurrency: two users modify the same article
fun main() {
    HibernatePersistenceConfiguration("concurrency")
        .managedClass(Article::class.java)
        .jdbcUrl("jdbc:mysql://localhost:3306/hbconcurrency?createDatabaseIfNotExist=true")
        .jdbcCredentials("root", "")
        .property(AvailableSettings.FORMAT_SQL, true)
        .createEntityManagerFactory()
        .use { sessionFactory ->
            // the [articles] table is recreated with a single article, without a log
            SqlLog.withoutLog {
                sessionFactory.schemaManager.drop(false)
                sessionFactory.schemaManager.create(false)
                sessionFactory.inTransaction { session -> session.persist(Article("bike", 10)) }
            }

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

                    // user 1 sells 1 bike
                    println("--- user 1 sells 1 bike")
                    val transaction1 = session1.beginTransaction()
                    bike1.currentStock -= 1
                    transaction1.commit()
                    println("stock saved by user 1: ${bike1.state()}")

                    // user 2 sells 3 bikes from outdated data (stock = 10, version 0)
                    println("--- user 2 sells 3 bikes")
                    val transaction2 = session2.beginTransaction()
                    bike2.currentStock -= 3
                    try {
                        session2.flush()
                        transaction2.commit()
                    } catch (e: OptimisticLockException) {
                        println("${e::class.simpleName} : ${e.message}")
                        transaction2.rollback()
                    }
                }
            }

            // resolution: user 2 reads the article again, with a new session, and starts over
            println("--- user 2 reads the bike again and starts over")
            sessionFactory.inTransaction { session ->
                val bike = session.find(Article::class.java, 1)
                println("stock read again: ${bike.state()}")
                bike.currentStock -= 3
                session.flush()
                println("stock saved by user 2: ${bike.state()}")
            }
        }
}

// the state of an article, for display: an extension function
fun Article.state() = "$currentStock (version $version)"

Results:

> gradlew -q :concurrency:run
--- 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 reads the bike again and starts over
    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
stock read again: 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 10–15: the Hibernate configuration for the [hbconcurrency] database and its single entity;
  • lines 18–21: the table is recreated with a single item, the bicycle, version 0. The function [withoutLog] from the library [tools] receives the action to be executed in the form of a lambda, written without parentheses since it is its only parameter (Chapter 7);
  • lines 28–29: Each user reads the bicycle in their own session; the two sessions are opened by two nested [use] functions and closed in the reverse order of their opening. The [version] column is read along with the others (lines 3–10);
  • lines 33–35: the [UPDATE] order 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] property is set to 1 (line 22);
  • lines 41–44: 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 throws a [OptimisticLockException] (line 33). We explicitly synchronize the session ([flush]) before the [commit] to trigger this exception as-is; if it were raised during the [commit], it would be encapsulated within a [RollbackException];
  • Lines 54–59: 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);
  • line 65: an extension function (Chapter 4) that formats an item’s status for display. It is defined within the program, without modifying the entity.

Two variants:

  • 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 [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.java, 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 Criteria API, 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] subproject lists the available methods:

package rawsql

import articles.Article
import articles.BaseArticles
import java.math.BigDecimal
import java.sql.Connection

// native SQL queries: written by hand, passed as they are to the DBMS
fun main() {
    BaseArticles.createSessionFactory().use { sessionFactory ->
        // the [articles] table is recreated with its six articles
        BaseArticles.initialize(sessionFactory)
        sessionFactory.openSession().use { session ->
            // 1 - an SQL query written by hand that returns entities
            println("--- createNativeQuery: entities")
            val minPrice = BigDecimal.valueOf(200)
            val expensive = session
                .createNativeQuery(
                    """
                    select * from articles
                    where price > :minPrice order by name""".trimIndent(), Article::class.java
                )
                .setParameter("minPrice", minPrice)
                .resultList
            expensive.forEach { println(it) }

            // 2 - a value passed as a parameter: no SQL injection possible
            println("--- SQL injection attempt with a parameter")
            val name = "x' or '1'='1"
            var nb = session
                .createNativeQuery("select count(*) from articles where name = :name", Long::class.java)
                .setParameter("name", name)
                .singleResult
            println("$nb article(s) found")
            // the same query built with a string template: never do this
            println("--- the same attempt with a string template")
            val sql = "select count(*) from articles where name = '$name'"
            nb = session.createNativeQuery(sql, Long::class.java).singleResult
            println("$nb article(s) found")

            // 3 - results that are not entities
            println("--- createNativeQuery: values")
            val names = session
                .createNativeQuery(
                    "select name from articles where currentStock < minimumStock", String::class.java
                )
                .resultList
            println("out of stock: ${names.joinToString(", ")}")
            val value = session
                .createNativeQuery("select sum(price * currentStock) from articles", BigDecimal::class.java)
                .singleResult
            println("value of the stock: %.2f euros".format(value))

            // 4 - an update statement
            println("--- createNativeMutationQuery")
            val nbRows = session.fromTransaction { _ ->
                session
                    .createNativeMutationQuery(
                        """
                        update articles set price = price * :coefficient
                        where name = :name""".trimIndent()
                    )
                    .setParameter("coefficient", BigDecimal("0.9"))
                    .setParameter("name", "bow")
                    .executeUpdate()
            }
            println("$nbRows row(s) modified")

            // 5 - the JDBC connection of the session, for ordinary JDBC code
            println("--- doWork: the JDBC connection")
            session.doWork { connection: Connection ->
                val query = "select name, price from articles where name = 'bow'"
                connection.createStatement().use { statement ->
                    statement.executeQuery(query).use { rows ->
                        while (rows.next()) {
                            val price = rows.getBigDecimal("price")
                            println("${rows.getString("name")} : %.2f euros".format(price))
                        }
                    }
                }
            }
        }
    }
}

Results:

> gradlew -q :rawsql:run
--- 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 string template
    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
value of the stock: 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 17–24: [createNativeQuery] executes a query (SQL) that returns rows from an entity's table, and the second parameter ([Article::class.java]) 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 written as a raw string (["""]), from which [trimIndent] removes the common indentation (Chapter 5). It is passed as-is to the DBMS (lines 3–6), except that the parameter named [:minPrice] becomes a JDBC parameter ([?]);
  • lines 29–34: an attempt to inject SQL. The value of [name] is passed as a parameter: it is compared as-is to the [name] column, and no item matches (line 16);
  • lines 37–38: the same query constructed using a string template (['$name']). Templates are so convenient in Kotlin that it’s tempting to use them to build a query: this is a serious mistake. The value of [name] becomes SQL code, the condition always evaluates to true (line 20), and the query returns 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 43–52: 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 data class (whose constructor receives the columns in order) or use [Array<Any>::class.java];
  • lines 56–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. The lambda receives the transaction as a parameter; since it does not use it, this parameter is named [_];
  • lines 71–77: [doWork] provides access to the session’s JDBC connection ([java.sql.Connection]) for standard JDBC code (Chapter 10): calling a stored procedure, use of a driver feature... It includes the function [use], which closes the statement ([Statement]) and the [ResultSet]. The price of the bow has indeed been reduced by 10% (line 40).

A note on line 71: the type of the lambda parameter ([connection: Connection]) is explicitly specified here, which is not standard practice in Kotlin. Without it, the compiler rejects the program:

error: type annotation class 'org.checkerframework.checker.nullness.qual.UnknownKeyFor' of the inferred type is inaccessible. Check the module classpath for missing or conflicting dependencies.

The Hibernate interface [Work], which the lambda implements, carries type annotations from the Checker Framework library on its parameter type; these annotations are used by Hibernate developers to verify the usage of [null] in their code. This library is not part of Hibernate’s dependencies; therefore, its annotations are unknown to the compiler, which refuses to infer a type that carries them. By specifying the parameter’s type ourselves, we avoid the inference—and the error. This type of issue sometimes arises when using a Java library from Kotlin: the Java code itself ignores these annotations.

A native query is not portable from one DBMS to another, and Hibernate cannot validate it before execution: it is reserved for cases where HQL is insufficient. HQL, in fact, covers a large part of SQL (joins of all kinds, subqueries, windowing functions, [union]...), and allows you to call a DBMS function 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] subproject introduces them:

package bulkupdate

import articles.Article
import articles.BaseArticles
import java.math.BigDecimal

// bulk processing: HQL update/delete statements, read-only, stateless session and batches
fun main() {
    BaseArticles.createSessionFactory().use { sessionFactory ->
        // the [articles] table is recreated with its six articles
        BaseArticles.initialize(sessionFactory)
        sessionFactory.openSession().use { session ->
            var transaction = session.beginTransaction()

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

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

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

            // 3 - read-only: the entities read are not tracked
            println("--- read-only")
            transaction = session.beginTransaction()
            val articles = session
                .createSelectionQuery("from Article order by id", Article::class.java)
                .setReadOnly(true)
                .resultList
            articles.forEach { println(it) }
            // the modification of a read-only entity is ignored
            articles.first().price = BigDecimal.ZERO
            println("session modified: ${session.isDirty}")
            transaction.commit()

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

        // 5 - a stateless session (StatelessSession) and statements sent in batches
        println("--- StatelessSession and batches of 3 statements")
        sessionFactory.inStatelessTransaction { session ->
            session.jdbcBatchSize = 3
            val articles = session
                .createSelectionQuery("from Article order by id", Article::class.java)
                .resultList
            for (article in articles) {
                article.currentStock += 1
                // each modification is requested explicitly: the entities are not tracked
                session.update(article)
            }
        }
    }
}

Results (the repetitive ending has been abbreviated):

> gradlew -q :bulkupdate:run
    SQL> -- start of transaction
--- restocking the articles that are out of stock
    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 17–23: A [update] command written as HQL, prepared by [createMutationQuery], and executed by [executeUpdate]. It becomes a single SQL [UPDATE] statement (lines 4–6), executed by the 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 (lines 2 and 18);
  • lines 27–29: a 10% increase in low prices. The constants for the HQL query ([1.1], [150]) are written directly into the text of the SQL command (lines 10–11);
  • lines 34–36: a [delete] HQL statement removes the bow, the only item priced over 550 euros (lines 14–16);
  • lines 43–50: a read-only operation ([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 49): the session has nothing to synchronize ([session.isDirty] is equal to [false], line 29) and [commit] does not send any commands. You can also set an entire session to read-only ([session.isDefaultReadOnly = true]);
  • lines 56–62: a trap. A command like [update] HQL 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 68–76: 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, and 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 69: the SQL commands will be sent to the DBMS in batches (see Chapter 10) of three: the JDBC driver transmits three commands in a single exchange with the server. In Kotlin, the [getJdbcBatchSize]/[setJdbcBatchSize] pair is used as a property. 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()]). With Kotlin, the [chunked] function for sequences and collections (Chapter 5) conveniently splits the data into chunks of this size.

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 this end, the Spring Framework provides, 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. A note regarding Kotlin: by default, Spring does not roll back the transaction for a controlled exception as defined by Java (a [IOException], for example). Kotlin does not distinguish these exceptions from others, but Spring does: a Kotlin function that throws a [IOException] within a transactional method will have its transaction committed! We therefore write [@Transactional(rollbackFor = [Exception::class])] to ensure the transaction is rolled back in all cases.

Inside a transactional method, ***[sessionFactory.currentSession]*** (the *[getCurrentSession] method) resets the current transaction session. A [dao] layer can then be a singleton that receives the [SessionFactory] (thread-safe) and asks it, in each method, for the current session: the rule “one session per unit of work, never shared between threads” is effortlessly enforced. Be careful with coroutines, however: the current session is associated with the thread, and a suspension function that switches threads in the middle of a transaction would lose its session. Therefore, we retain the blocking transactional methods and call them from a coroutine within a [withContext(Dispatchers.IO)] block, as shown in the example [async]*.

11.14.2. The [injection] subproject

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

// Hibernate and dependency injection: Spring ORM and declarative transactions
plugins {
    kotlin("jvm")
    // makes the classes annotated by Spring open (@Configuration, @Transactional...)
    kotlin("plugin.spring")
    application
}

repositories {
    mavenCentral()
}

dependencies {
    // the [Article] entity and the [hbarticles] database (Hibernate, MySQL and the SQL log included)
    implementation(project(":articles"))
    // Spring: container, declarative transactions, Hibernate integration
    implementation("org.springframework:spring-context:7.0.9")
    implementation("org.springframework:spring-orm:7.0.9")
    // the HikariCP connection pool
    implementation("com.zaxxer:HikariCP:7.0.2")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the program run by [gradlew -q :injection:run]
    mainClass = "injection.MainKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • Line 5: The [all-open] plugin configured for Spring (Chapter 6). It makes the classes annotated with [@Configuration], as well as [@Transactional] and a few others ([@Component], [@Service], [@Async]....), as well as their methods. We’ll see why this is essential;
  • line 15: the [Article] entity and the [hbarticles] database (including Hibernate, MySQL, and the SQL log);
  • Lines 17–18: the Spring container and Hibernate integration; [spring-orm] includes [spring-tx], the transaction library;
  • line 19: the HikariCP connection pool (Chapter 10);
  • lines 36–39: Programs launched by Gradle write their output to UTF-8 so that accented characters display correctly on Windows (Chapter 2).

The Spring configuration is written in Kotlin, 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
class Config {

    // the HikariCP connection pool (chapter 10)
    @Bean(destroyMethod = "close")
    fun dataSource() = HikariDataSource().apply {
        jdbcUrl = BaseArticles.URL
        username = BaseArticles.USER
        password = BaseArticles.PASSWORD
    }

    // the SessionFactory, built by Spring with the connection pool
    @Bean
    fun sessionFactory(dataSource: DataSource) = LocalSessionFactoryBean().apply {
        setDataSource(dataSource)
        setAnnotatedClasses(Article::class.java)
        hibernateProperties = Properties().apply { put(AvailableSettings.FORMAT_SQL, "true") }
    }

    // the transaction manager: one Hibernate session per transaction
    @Bean
    fun transactionManager(sessionFactory: SessionFactory) = HibernateTransactionManager(sessionFactory)

    // the [dao] layer: a singleton that receives the SessionFactory
    @Bean
    fun articlesDao(sessionFactory: SessionFactory) = ArticlesDao(sessionFactory)
}
  • line 18: 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–26: the HikariCP connection pool from Chapter 10, configured in a [apply] block: The modifiers [setJdbcUrl], [setUsername], and [setPassword] are used as properties. The attribute [destroyMethod] specifies the method to be called when the container is shut down. The inferred return type of the function is [HikariDataSource]: this is the type under which Spring registers the bean;
  • lines 31–34: [SessionFactory]. A *[LocalSessionFactoryBean] is a factory for *[SessionFactory]*: Spring registers the object it produces in the container, and it is a *[SessionFactory] that is obtained by requesting this bean. It receives the connection pool (which replaces Hibernate’s built-in pool), the entities, and the Hibernate properties. We write [setDataSource(dataSource)] rather than [dataSource = dataSource], which would assign the parameter to itself (and would be rejected, since a parameter is read-only);
  • line 39: the transaction manager;
  • line 43: the [dao] layer, which receives the [SessionFactory].

The [dao] layer:

package injection

import articles.Article
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
class ArticlesDao(
    // the SessionFactory, shared by all the threads
    private val sessionFactory: SessionFactory
) {

    // do two requests for the current session, in the same transaction, return the same session?
    fun sameSession() = sessionFactory.currentSession === sessionFactory.currentSession

    // the session of the transaction
    fun session(): Session = sessionFactory.currentSession

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

    // the article with the given name
    @Transactional(readOnly = true)
    fun article(name: String): Article =
        sessionFactory.currentSession
            .createSelectionQuery("from Article where name = :name", Article::class.java)
            .setParameter("name", name)
            .singleResult

    // the sale of a quantity of an article: an exception rolls back the transaction
    fun sell(name: String, quantity: Int) {
        val article = article(name)
        article.currentStock -= quantity
        // check throws an IllegalStateException if the condition is false
        check(article.currentStock >= 0) { "insufficient stock for the article [$name]" }
    }
}
  • line 9: all public methods of the class are transactional;
  • line 12: the [SessionFactory], received by the primary constructor;
  • line 16: within the same transaction, do two requests from the current session return the same session? The [===] operator compares the references (Chapter 3);
  • line 19: the transaction’s session, returned to the caller (for the following experiment: this is not done in a real application);
  • lines 22–25: a read-only transaction ([readOnly = true]): Spring sets the session to read-only and does not synchronize it;
  • lines 37–41: the sale of a quantity of an item. If the inventory becomes negative, the Kotlin function [check] (Chapter 3) throws a [IllegalStateException], an unhandled exception: Spring rolls back the transaction. The call to the [article] method (line 38) is an internal call within the object, which does not go through the proxy: it executes within the [sell] transaction.

The proxy created by Spring for the [ArticlesDao] class is a subclass of it, generated at runtime by the CGLIB library, which redefines each public method to wrap it in a transaction. Without the [plugin.spring] plugin, the Kotlin class would be final, and Spring would not be able to derive from it. If you compile the subproject without this plugin (by manually declaring the [Config] class and its [open] functions to work around the first error, which we already encountered in Chapter 6), the container refuses to start:

1
2
3
Exception in thread "main" org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'articlesDao' defined in injection.Config: Could not generate CGLIB subclass of class injection.ArticlesDao: Common causes of this problem include using a final class or a non-visible class
Caused by: org.springframework.aop.framework.AopConfigException: Could not generate CGLIB subclass of class injection.ArticlesDao: Common causes of this problem include using a final class or a non-visible class
Caused by: java.lang.IllegalArgumentException: Cannot subclass final class injection.ArticlesDao

A more insidious pitfall awaits anyone who manually opens the class ([open class ArticlesDao]) without opening its methods: the proxy is then created, but it cannot override the methods that remain final. Calling these methods directly executes the code of the [ArticlesDao]... class on the proxy object, whose fields have not been initialized (Spring creates it without calling the class’s constructor). The result, obtained by compiling the subproject in this way, is confusing:

--- sessions and transactions
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "org.hibernate.SessionFactory.getCurrentSession()" because "this.sessionFactory" is null

The property [sessionFactory], which is of a non-nullable type, is set to [null]! The plugin prevents these two errors: with it, the class and all its methods are open.

The main program:

package injection

import articles.BaseArticles
import org.hibernate.HibernateException
import org.hibernate.SessionFactory
import org.springframework.beans.factory.getBean
import org.springframework.context.annotation.AnnotationConfigApplicationContext

// Hibernate in the Spring container: current session and declarative transactions
fun main() {
    // the Spring container, configured by the [Config] class
    AnnotationConfigApplicationContext(Config::class.java).use { context ->
        // the [articles] table is recreated with its six articles
        val sessionFactory = context.getBean<SessionFactory>()
        BaseArticles.initialize(sessionFactory)
        val dao = context.getBean<ArticlesDao>()

        // 1 - during a transaction, the current session is always the same
        println("--- sessions and transactions")
        println("one transaction: same session = ${dao.sameSession()}")
        // 2 - two transactions: two sessions, closed at the end of their transaction
        val session1 = dao.session()
        val session2 = dao.session()
        println("two transactions: same session = ${session1 === session2}, " +
                "first session open = ${session1.isOpen}")
        // 3 - outside a transaction, there is no current session
        try {
            sessionFactory.currentSession
        } catch (e: HibernateException) {
            println("${e::class.simpleName} : ${e.message}")
        }

        // 4 - transactional methods of the [dao] layer
        println("--- articles out of stock")
        println("out of stock: ${dao.articlesOutOfStock()}")
        println("--- sale of 2 bikes")
        dao.sell("bike", 2)
        println("--- sale of 50 pumps")
        try {
            dao.sell("pump", 50)
        } catch (e: IllegalStateException) {
            println("${e::class.simpleName} : ${e.message}")
        }
        println("--- state of the database")
        println(dao.article("bike"))
        println(dao.article("pump"))
    }
}

Results:

> gradlew -q :injection:run
--- 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 12: the Spring container, configured by the [Config] class. It is closed at the end of the [use] block, which closes the [SessionFactory] and the connection pool;
  • line 14: the [SessionFactory] constructed by Spring, obtained via Spring’s [getBean<T>()] extension function (Chapter 6); it is used to recreate the product table;
  • line 20: a transaction (lines 3–4): within the same transaction, the current session remains the same (line 5);
  • lines 22–25: two calls, two transactions (lines 6–9), two different sessions; the first is closed, since its transaction is complete (line 10). The property [isOpen] is the session’s method [isOpen()];
  • lines 28–29: outside a transaction, there is no current session: [getCurrentSession] launches a [HibernateException] (line 11). This is the classic error of a method that omitted the annotation [@Transactional];
  • line 35: a read-only transaction (lines 13–17);
  • line 37: the sale of two bicycles. The transaction is committed, and session synchronization sends the [UPDATE] (lines 20–33);
  • lines 40–41: 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 45–46: 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 : JpaRepository<Article, Int>] provides [findAll], [findById], [save], [delete]..., and a declared method [fun findByPriceLessThan(price: BigDecimal): List<Article>] is automatically translated into a query. Spring Data adds a few extension functions for Kotlin, such as [findByIdOrNull], which returns a [Article?] rather than a [Optional<Article>].

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, thanks to the composite architecture.

Image

Version 7 consists of two subprojects from the structure in Chapter 11, stored in the [TaxV7] folder:

11-hibernate/
  settings.gradle.kts                   the subprojects and the included build (chapter 6)
  TaxV7/
    tax-dao-hibernate/                  library: the Hibernate [dao] layer
      build.gradle.kts
      src/main/kotlin/tax/dao/
        Bracket.kt                      the entity
        HibernateTax.kt                 the implementation of ITaxDao
        CreateDatabase.kt               the creation of the [hbtaxes] database
      src/main/resources/
        logback.xml
    tax-ui-hibernate/                   executable: integration of the layers
      build.gradle.kts
      src/main/kotlin/tax/ui/
        MainHibernate.kt
      tax-hibernate.properties
      tax-hibernate-error.properties

The [TaxV7] folder does not have a build file: like the [TaxV5] folder in Chapter 6, it is simply a collection of subprojects. The modules for version 5 ([tax-dao], [tax-business], [tax-ui], in version 5.0) are provided by the build from Chapter 6, included via the line [includeBuild("../06-architectures")] in the file [settings.gradle.kts]: Gradle replaces any [examples.tax:tax-xxx:5.0] dependency with the corresponding subproject from this build, which it compiles as needed. There is nothing to install beforehand, unlike with Maven, where you first had to enter [mvn install] in the version 5 folder.

11.15.2. The [dao] layer

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

// TaxV7: the tax-dao-hibernate subproject, a library (the [dao] layer written with Hibernate)
plugins {
    kotlin("jvm")
    // the [Bracket] entity: the [jpa] and [allopen] plugins
    kotlin("plugin.jpa")
    kotlin("plugin.allopen")
    // the library plugin: it adds the [api] configuration
    `java-library`
    // [gradlew -q :TaxV7:tax-dao-hibernate:run] creates the [hbtaxes] database
    application
}

// the coordinates of the library: examples.tax:tax-dao-hibernate:7.0
group = "examples.tax"
version = "7.0"

repositories {
    mavenCentral()
}

dependencies {
    // the [dao] layer of version 5 (chapter 6): entities, ITaxDao interface, HardwiredTax
    api("examples.tax:tax-dao:5.0")
    // Hibernate, the MySQL JDBC driver and Logback
    implementation("org.hibernate.orm:hibernate-core:7.4.5.Final")
    runtimeOnly("com.mysql:mysql-connector-j:9.7.0")
    runtimeOnly("ch.qos.logback:logback-classic:1.5.38")
}

// the classes annotated this way are opened by the [allopen] plugin
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the creation of the [hbtaxes] database
    mainClass = "tax.dao.CreateDatabaseKt"
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • lines 5–6: entity plugins: the subproject defines the entity [Bracket];
  • line 8: the subproject is a library that exposes some of its dependencies through the configuration [api];
  • line 10: the [application] plugin, used to create the database (line 47);
  • line 23: the [dao] layer of version 5. This is a dependency on [api]: the class [HibernateTax] implements the interface [ITaxDao] from this library, which its users must therefore also see;
  • lines 25–27: Hibernate, the JDBC driver for MySQL, and Logback. Hibernate is a dependency of [implementation]: it is used internally by the library but does not appear in its public interface;
  • lines 31–34: the standard configuration for the [allopen] plugin;
  • lines 50–53: Programs launched by Gradle write their output to UTF-8 so that accented characters display correctly on Windows (Chapter 2).

The [TaxBracket] data class in version 5 cannot be a Hibernate entity: it is a data class (we’ve seen why this isn’t a good idea for an entity), it is immutable, and it has no identifier. We do not modify it, 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")
class Bracket(
    @Column(name = "limits", nullable = false, precision = 10, scale = 2)
    private var limit: BigDecimal,

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

    @Column(name = "coeffn", nullable = false, precision = 10, scale = 2)
    private var coeffN: BigDecimal,
) {
    // the primary key, which does not exist in the TaxBracket class
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private var id: Int? = null
}
  • row 3: the table [brackets];
  • rows 6–12: the three columns, declared in the primary constructor, with appropriate [decimal] 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. The properties are private: the entity is used only to populate the table ([CreateDatabase]) and to write queries; the rest of the application never sees it. Hibernate, which accesses the fields directly, does not require them to be public;
  • lines 15–17: the primary key, which is also used to sort the brackets.

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

package tax.dao

import tax.entities.TaxException
import tax.entities.TaxBracket
import jakarta.persistence.PersistenceException
import org.hibernate.jpa.HibernatePersistenceConfiguration

// a [dao] layer that reads the tax brackets from a database with Hibernate
class HibernateTax(url: String, user: String, password: String) : ITaxDao {

    companion object {
        // error codes specific to this implementation
        const val ACCESS_ERROR = 1
        const val EMPTY_TABLE = 2
    }

    // tax brackets, read once and for all at construction
    override val taxBrackets: List<TaxBracket> = try {
        // a short-lived SessionFactory, just long enough to read the table
        HibernatePersistenceConfiguration("tax")
            .managedClass(Bracket::class.java)
            .jdbcUrl(url)
            .jdbcCredentials(user, password)
            .createEntityManagerFactory()
            .use { sessionFactory ->
                // the rows of the table are projected to the TaxBracket class of the application
                val hql = "select b.limit, b.coeffR, b.coeffN from Bracket b order by b.id"
                sessionFactory.fromSession { session ->
                    session.createSelectionQuery(hql, TaxBracket::class.java).resultList
                }
            }
    } catch (e: PersistenceException) {
        // Hibernate or DBMS error: it is wrapped in a TaxException
        throw TaxException("Error while reading the tax brackets from the database",
            e, ACCESS_ERROR)
    }

    init {
        // the [business] layer needs at least one bracket
        if (taxBrackets.isEmpty()) {
            throw TaxException("The tax brackets table is empty", code = EMPTY_TABLE)
        }
    }
}
  • lines 11–14: the error codes for this implementation, as with [FileTax] and [JsonTax];
  • line 9: the [dao] layer receives the URL from the database, along with the user and their password. These are simply constructor parameters ([val] is not written: they do not become properties, since they are used only for construction). It reads the brackets once and for all during construction: it will be a singleton;
  • line 18: the property [taxBrackets] of the interface [ITaxDao], initialized by an expression [try] (Chapter 3): its value is that of the block [try]; if an error occurs, [catch] throws an exception;
  • Lines 20–25: A short-lived [SessionFactory]—just long enough to read the table—immediately closed by [use]. 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 27–29: Reading the table in primary key order. The query is a projection onto the data class [TaxBracket]: Hibernate requests only the three relevant columns from the DBMS and directly creates the application objects by calling the constructor of *[TaxBracket] with the three values, without going through *[Bracket] entities. The value returned by [fromSession], and then by [use], is the list that was read: this is the value of the property;
  • Lines 32–35: 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 24), which connects to the server: it is also contained within the ***[try]** block;
  • lines 38–41: an empty table is also an error: the [business] layer requires at least one row. The [init] block executes after the property is initialized, which precedes it in the class. The error code is passed via a named argument ([code = EMPTY_TABLE]), since the exception has no cause (Chapter 6).

11.15.3. The database

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

package tax.dao

import org.hibernate.jpa.HibernatePersistenceConfiguration
import org.hibernate.tool.schema.Action

// creation of the [hbtaxes] database: the [brackets] table, filled with the brackets of HardwiredTax
fun main() {
    HibernatePersistenceConfiguration("tax")
        .managedClass(Bracket::class.java)
        .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()
        .use { sessionFactory ->
            // the brackets of the tax scale, those of version 5, numbered from 1 by the DBMS
            sessionFactory.inTransaction { session ->
                for ((limit, coeffR, coeffN) in HardwiredTax().taxBrackets) {
                    session.persist(Bracket(limit, coeffR, coeffN))
                }
            }
        }
}
  • line 10: the database [hbtaxes], created by MySQL if it does not exist;
  • line 13: displays the SQL statements, without formatting;
  • line 15: the table is deleted and then recreated when [SessionFactory] is created;
  • lines 19–21: one [Bracket] entity per bracket of the [HardwiredTax] class, persisted in a transaction. The [for] loop breaks down each [TaxBracket] into its three components ([(limit, coeffR, coeffN)]) using the functions [component1], [component2], and [component3], which are generated for a data class (Chapter 4).

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

<?xml version="1.0" encoding="UTF-8"?>
<!-- Logback configuration: only 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:

> gradlew -q :TaxV7:tax-dao-hibernate:run
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 see the pay scale brackets, with the last one having a zero limit (it is not used by layer [business], which selects the last bracket when no other applies).

To create the database on your own system:

  • run MySQL (in Laragon; see the appendix; user [root] with no password);
  • navigate to the [11-hibernate] folder and enter the command [gradlew -q :TaxV7:tax-dao-hibernate:run] (or run [CreateDatabase.kt] in IntelliJ IDEA, with the green triangle placed in front of its function [main]). Gradle compiles the Version 5 modules at the same time, in the adjacent folder [06-architectures].

The database [hbtaxes] is then created, with its table [brackets] populated. If the command fails, verify that MySQL is running and that the URL for [CreateDatabase.kt] 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] subproject combines the following layers:

// TaxV7: the tax-ui-hibernate subproject, the integration of the layers
plugins {
    kotlin("jvm")
    application
}

group = "examples.tax"
version = "7.0"

repositories {
    mavenCentral()
}

dependencies {
    // the [business] and [ui] layers of version 5, unchanged (composite build)
    implementation("examples.tax:tax-business:5.0")
    implementation("examples.tax:tax-ui:5.0")
    // the new [dao] layer, which brings Hibernate, the JDBC driver and Logback
    implementation(project(":TaxV7:tax-dao-hibernate"))
    // the Spring dependency injection container
    implementation("org.springframework:spring-context:7.0.9")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        javaParameters = true
    }
}

application {
    // the main program: gradlew -q :TaxV7:tax-ui-hibernate:run [--args="file.properties"]
    mainClass = "tax.ui.MainHibernateKt"
}

tasks.named<JavaExec>("run") {
    // the program reads the keyboard
    standardInput = System.`in`
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}
  • lines 16–17: the layers [business] and [ui] from version 5. The [tax-ui] subproject contains the main program for version 5; it is used here as a library, specifically for its [Dialog] object;
  • line 19: the new layer [dao], which includes Hibernate, the driver JDBC, and Logback;
  • line 21: the Spring container;
  • line 34: the main class;
  • lines 37–39: the program reads from the keyboard (Chapter 6);
  • lines 42–45: the programs launched by Gradle write their output to UTF-8, so that accented characters display correctly on Windows (Chapter 2).

The configuration file [tax-hibernate.properties], located in the subproject 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] references 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.util.Properties
import kotlin.io.path.Path
import kotlin.io.path.bufferedReader
import kotlin.system.exitProcess
import org.springframework.beans.factory.getBean
import org.springframework.context.support.GenericApplicationContext
import org.springframework.context.support.registerBean

// version 7: the [dao] layer reads the tax brackets from a database with Hibernate
fun main(args: Array<String>) {
    // the configuration file: tax-hibernate.properties by default, or the one passed as an argument
    val configFile = args.firstOrNull() ?: "tax-hibernate.properties"

    // integration of the layers
    val business: ITaxBusiness = try {
        // reading the configuration, in the current folder
        val config = Properties()
        Path(configFile).bufferedReader().use { config.load(it) }
        val url = config.getProperty("jdbc.url")
            ?: throw TaxException("Database URL [jdbc.url] missing from the file $configFile")
        val user = config.getProperty("jdbc.user", "root")
        val password = config.getProperty("jdbc.password", "")

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

        // starting the container: it creates the [dao] layer, then the [business] layer
        context.refresh()
        context.getBean<ITaxBusiness>()
    } catch (e: Exception) {
        // display of the error and end of the program
        Dialog.displayError(e)
        exitProcess(1)
    }

    // the dialog with the user, that of version 5
    Dialog.run(business)
}
  • The file is not named [Main.kt]: it would be compiled into a class named [tax.ui.MainKt], which already exists in the [tax-ui] module of version 5, on which we depend. The file [MainHibernate.kt] generates a class named [tax.ui.MainHibernateKt];
  • lines 26–29: the required URL database, the username, and the password, read from the configuration. [getProperty] returns [null] for a missing key: the Elvis operator ([?:]) then evaluates to [TaxException] ([throw] is a Kotlin expression);
  • line 34: the [dao] layer is a [HibernateTax], created by a lambda that passes it the parameters from the database;
  • line 36: the [business] layer in version 5: the container will pass the [dao] layer to its constructor;
  • line 48: the dialog for version 5.

11.15.5. Executions

The user’s input, redirected to the program to obtain these results, was placed after the questions:

1
2
3
4
5
6
7
8
> gradlew -q :TaxV7:tax-ui-hibernate:run
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 control 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 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:

> gradlew -q :TaxV7:tax-ui-hibernate:run --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 in a [BeanCreationException], which was displayed by the main program.

Changing the DBMS is equivalent to changing the URL in the database 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 program [CreateDatabase.kt] does without any modifications other than to its own URL.

11.16. Conclusion: JDBC, JdbcClient, jOOQ—Exposed 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 ([spring-jdbc] library), adds to JDBC a flexible API 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.java).list()]. You write the SQL; Spring handles the parameters and copies the columns into the objects (including data classes, whose constructors receive the columns). This is a “micro-ORM”;
  • jOOQ generates, from the database schema, classes that allow you to write a SQL in Java or Kotlin 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;
  • Exposed, from JetBrains, is the database access library written in Kotlin and for Kotlin. Tables are described there using Kotlin objects ([object Articles : Table("articles")], with columns such as [val price = decimal("price", 10, 2)]), without code generation, and queries are written in a dedicated language (“ DSL”), which resembles SQL and is verified by the compiler: [Articles.selectAll().where { Articles.price greater minPrice }]. Exposed also offers a second API, a lightweight version of ORM (entity classes linked to tables), and relies on JDBC or, optionally, on non-blocking drivers (R2DBC) that can be used from coroutines. It takes advantage of everything we’ve covered about Kotlin: extension functions, lambdas with receivers, infix functions ([greater]), and nullable types for optional columns.
 
Strengths
Weaknesses
JDBC
Full control over SQL; performance; no dependencies
a lot of repetitive code (parameters, reading columns), streamlined in Kotlin by [use] and extension functions; 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
Exposed
Designed for Kotlin; queries verified by the compiler, without code generation; lightweight
Kotlin-specific; less widespread than Hibernate, and less documentation; the schema is described manually in the code
Hibernate
object-oriented model; change tracking; relationships; HQL and Criteria API; Jakarta Persistence standard; independent of the DBMS
an additional layer to understand (sessions, states, lazy loading); generated SQL code to monitor; less suitable for complex bulk processing; some adaptations for Kotlin ([jpa] and [allopen] plugins, no data classes for entities)

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, in both Kotlin and Java;
  • JDBC, JdbcClient, jOOQ or Exposed are suitable when you want precise control over the SQL 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. Exposed is a natural choice for an application written entirely in Kotlin, particularly outside of Spring (using the Ktor web framework from JetBrains, for example);
  • these approaches can be combined: an application can use Hibernate for the bulk of its operations and hand-written SQL code—in the form of native queries—with [JdbcClient] or 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. Spring’s guide on using JPA in Kotlin summarizes the concepts covered in this chapter (plugins, entities, nullability): Building web applications with Spring Boot and Kotlin. Exposed is presented on its GitHub page, which links to its documentation: JetBrains Exposed.