Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/projects/dev/hibernate/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.dataframe)
alias(libs.plugins.kotlin.jpa)
alias(libs.plugins.ktlint.gradle)

application
Expand Down
2 changes: 2 additions & 0 deletions examples/projects/dev/hibernate/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gr

# The Kotlin DataFrame Compiler plugin is the same version as the Kotlin plugin.
kotlin-dataframe = { id = "org.jetbrains.kotlin.plugin.dataframe", version.ref = "kotlin" }

kotlin-jpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package org.jetbrains.kotlinx.dataframe.examples.hibernate

import jakarta.persistence.Column
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
import org.jetbrains.kotlinx.dataframe.annotations.ColumnName
import org.jetbrains.kotlinx.dataframe.annotations.DataSchema
Expand All @@ -17,9 +20,10 @@ class AlbumsEntity(
@Column(name = "AlbumId")
var albumId: Int? = null,
@Column(name = "Title", length = 160, nullable = false)
var title: String = "",
@Column(name = "ArtistId", nullable = false)
var artistId: Int = 0,
var title: String,
@ManyToOne(fetch = FetchType.LAZY, optional = false)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Artist is loaded only if it is needed, and an album must have an artist

@JoinColumn(name = "ArtistId", nullable = false)
var artist: ArtistsEntity,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way Hibernate knows and tracks the connection (FK) between entities: we don't need to manually flush ids into the DB and call them with !!.

)

@Entity
Expand All @@ -30,7 +34,7 @@ class ArtistsEntity(
@Column(name = "ArtistId")
var artistId: Int? = null,
@Column(name = "Name", length = 120, nullable = false)
var name: String = "",
var name: String,
)

@Entity
Expand All @@ -41,9 +45,9 @@ class CustomersEntity(
@Column(name = "CustomerId")
var customerId: Int? = null,
@Column(name = "FirstName", length = 40, nullable = false)
var firstName: String = "",
var firstName: String,
@Column(name = "LastName", length = 20, nullable = false)
var lastName: String = "",
var lastName: String,
@Column(name = "Company", length = 80)
var company: String? = null,
@Column(name = "Address", length = 70)
Expand All @@ -61,7 +65,7 @@ class CustomersEntity(
@Column(name = "Fax", length = 24)
var fax: String? = null,
@Column(name = "Email", length = 60, nullable = false)
var email: String = "",
var email: String,
@Column(name = "SupportRepId")
var supportRepId: Int? = null,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import jakarta.persistence.criteria.CriteriaDelete
import jakarta.persistence.criteria.CriteriaQuery
import jakarta.persistence.criteria.Root
import org.hibernate.FlushMode
import org.hibernate.Session
import org.hibernate.SessionFactory
import org.hibernate.cfg.Configuration
import org.hibernate.hikaricp.internal.HikariCPConnectionProvider
import org.hibernate.jpa.HibernatePersistenceConfiguration
import org.hibernate.tool.schema.Action
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.jetbrains.kotlinx.dataframe.DataRow
import org.jetbrains.kotlinx.dataframe.api.asSequence
Expand Down Expand Up @@ -43,17 +46,16 @@ fun main() {
}

private fun SessionFactory.insertSampleData() {
withTransaction { session ->
inTransaction { session ->
// a few artists and albums (minimal, not used further; just demo schema)
val artist1 = ArtistsEntity(name = "AC/DC")
val artist2 = ArtistsEntity(name = "Queen")
session.persist(artist1)
session.persist(artist2)
session.flush()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Albums entity now refers to the Artists entity instead of id in the DB


session.persist(AlbumsEntity(title = "High Voltage", artistId = artist1.artistId!!))
session.persist(AlbumsEntity(title = "Back in Black", artistId = artist1.artistId!!))
session.persist(AlbumsEntity(title = "A Night at the Opera", artistId = artist2.artistId!!))
session.persist(AlbumsEntity(title = "High Voltage", artist = artist1))
session.persist(AlbumsEntity(title = "Back in Black", artist = artist1))
session.persist(AlbumsEntity(title = "A Night at the Opera", artist = artist2))
// customers we'll analyze using DataFrame
session.persist(
CustomersEntity(
Expand Down Expand Up @@ -89,15 +91,15 @@ private fun SessionFactory.loadCustomersAsDataFrame(): DataFrame<DfCustomers> =
val root: Root<CustomersEntity> = criteriaQuery.from(CustomersEntity::class.java)
criteriaQuery.select(root)

session.createQuery(criteriaQuery)
session.createSelectionQuery(criteriaQuery)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createSelectionQuery returns SelectionQuery instead of Query, exposing only methods which are relevant to selection queries.
When used with HQL, createSelectionQuery also throws an exception if passed a query string that begins with "insert", "delete", or "update".

.resultList
.map { c ->
DfCustomers(
address = c.address,
city = c.city,
company = c.company,
country = c.country,
customerId = c.customerId ?: -1,
customerId = c.customerId ?: error("Loaded customer must have a generated id"),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems better to fail soon in case of null id, even though this situation is highly unlikely

email = c.email,
fax = c.fax,
firstName = c.firstName,
Expand All @@ -118,7 +120,7 @@ private fun SessionFactory.loadCustomersAsDataFrame(): DataFrame<DfCustomers> =
}

/** DTO used for aggregation projection. */
private data class CountryCountDto(val country: String, val customerCount: Long)
private data class CountryCountDto(val country: String?, val customerCount: Long)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

country is nullable in CustomersEntity. Without this change, if some of the customers in insertSampleData is created without a country, the example would fail with NPE.


/**
* **Hibernate + Criteria API:**
Expand All @@ -132,9 +134,8 @@ private fun SessionFactory.countCustomersPerCountryWithHibernate() {
val cb = session.criteriaBuilder
val cq: CriteriaQuery<CountryCountDto> = cb.createQuery(CountryCountDto::class.java)
val root: Root<CustomersEntity> = cq.from(CustomersEntity::class.java)

val countryPath = root.get<String>("country")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found a cool thing here.

Hibernate has so-called Static Metamodel Generator which allows users to construct Criteria queries in a strongly-typed way. For each entity, it generates static metamodel classes, and their properties can be used for type-safe path references instead of strings.

Using it in this example would mean that this generator would create, among others, a CustomersEntity_ class (and put it into the build folder), and we could write, for example,

root.get(CustomersEntity_.country)

instead of

root.get<String?>("country")

I didn't use it here because even though it allows type-safe access, it

  1. requires adding two more dependencies: hibernate-processor and kapt, so it seemed unjustified to add this to replace just two strings;
  2. might introduce unnecessary complexity and distract users from the main thing we want to show them: how to use DataFrame :)

What do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kapt? hmm, maybe if it were KSP it might be nice, but kapt is quite Java oriented. That said, if the "hibernate get started" guides use it, we should probably copy it. If it's just an optional addition, we can leave it out :)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that hibernate-processor doesn't support KSP :(

The guide says: you can use strings, but type-safety is a compelling advantage :)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm, then it might be worth adding. We also boast about type-safety after all, plus it's a good test to see if our plugin and their don't interfere :)

val idPath = root.get<Long>("customerId")
val countryPath = root.get<String?>("country")
val idPath = root.get<Int?>("customerId")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's Int? in the CustomersEntity. countExpr will still have type JpaExpression<Long!>! because of the count function, so customerCount in CountryCountDto should remain Long.


val countExpr = cb.count(idPath)

Expand All @@ -148,7 +149,7 @@ private fun SessionFactory.countCustomersPerCountryWithHibernate() {
cq.groupBy(countryPath)
cq.orderBy(cb.desc(countExpr))

val results = session.createQuery(cq).resultList
val results = session.createSelectionQuery(cq).resultList
results.forEach { dto ->
println("${dto.country}: ${dto.customerCount} customers")
}
Expand All @@ -175,22 +176,18 @@ private fun DataFrame<DfCustomers>.analyzeAndPrintResults() {
.print(columnTypes = true, borders = true)
}

private fun SessionFactory.replaceCustomersFromDataFrame(df: DataFrame<DfCustomers>) {
withTransaction { session ->
private fun SessionFactory.replaceCustomersFromDataFrame(df: DataFrame<DfCustomers>) =
inTransaction { session ->
val criteriaBuilder: CriteriaBuilder = session.criteriaBuilder
val criteriaDelete: CriteriaDelete<CustomersEntity> =
criteriaBuilder.createCriteriaDelete(CustomersEntity::class.java)
criteriaDelete.from(CustomersEntity::class.java)

@Allex-Nik Allex-Nik Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be a single transaction. Otherwise, if the deletion query succeeds and the second part fails, the transaction will revert to the state when the DB is empty.

session.createMutationQuery(criteriaDelete).executeUpdate()
}

withTransaction { session ->
df.asSequence().forEach { row ->
session.persist(row.toCustomersEntity())
}
}
}

private fun DataRow<DfCustomers>.toCustomersEntity(): CustomersEntity =
CustomersEntity(
Expand All @@ -209,40 +206,22 @@ private fun DataRow<DfCustomers>.toCustomersEntity(): CustomersEntity =
supportRepId = this.supportRepId,
)

private inline fun <T> SessionFactory.withSession(block: (session: org.hibernate.Session) -> T): T =

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do not need withSession and withTransaction because their logic is roughly the same as in the built-in inTransaction method. The difference is that the exception handling block in rollback in the built-in version applies addSuppressed to the exception (instead of throwing it) without replacing the original exception.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed, best not reinvent the wheel

openSession().use(block)

private inline fun SessionFactory.withTransaction(block: (session: org.hibernate.Session) -> Unit) {
withSession { session ->
session.beginTransaction()
try {
block(session)
session.transaction.commit()
} catch (e: Exception) {
session.transaction.rollback()
throw e
}
}
}

/** Read-only transaction helper for SELECT queries to minimize overhead. */
private inline fun <T> SessionFactory.withReadOnlyTransaction(block: (session: org.hibernate.Session) -> T): T =
withSession { session ->
session.beginTransaction()
private inline fun <T> SessionFactory.withReadOnlyTransaction(crossinline block: (session: Session) -> T): T =
fromTransaction { session ->
// Minimize overhead for read operations
session.isDefaultReadOnly = true

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was already there, just cool things to find out about optimizations for reading.

session.isDefaultReadOnly = true

changes the default mode for entities and proxies in a session from modifiable to read-only. This eliminates the need for Hibernate to dirty-check such objects. Dirty-checking is tracking if an entity was changed and synchronizing these changes with the database. Dirty-checking simplifies development, but introduces overhead: Hibernate has to keep two copies of each object (the snapshot and the live object), and Hibernate has to compare entities with their copies during a flush. With read-only entities, Hibernate doesn't do any of these.

session.hibernateFlushMode = FlushMode.MANUAL

Recommended for read-only transactions. With FlushMode.AUTO, Hibernate synchronizes entities with the database before any query. When we can guarantee that the whole transaction is read-only, this synchronization is not needed.

session.hibernateFlushMode = FlushMode.MANUAL
try {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done by the built-in fromTransaction method

val result = block(session)
session.transaction.commit()
result
} catch (e: Exception) {
session.transaction.rollback()
throw e
}
block(session)
}

private fun buildSessionFactory(): SessionFactory {
// Load configuration from resources/hibernate/hibernate.cfg.xml
return Configuration().configure("hibernate/hibernate.cfg.xml").buildSessionFactory()
}
private fun buildSessionFactory(): SessionFactory =
HibernatePersistenceConfiguration("hibernate-example")
.managedClasses(CustomersEntity::class.java, ArtistsEntity::class.java, AlbumsEntity::class.java)
.jdbcUrl("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1")
.jdbcCredentials("sa", "")
.schemaToolingAction(Action.CREATE_DROP)
.showSql(true, true, true)
.property("hibernate.connection.provider_class", HikariCPConnectionProvider::class.java)
.property("hibernate.hikari.maximumPoolSize", 5)
.createEntityManagerFactory()

This file was deleted.

1 change: 1 addition & 0 deletions examples/projects/hibernate/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.dataframe)
alias(libs.plugins.kotlin.jpa)
alias(libs.plugins.ktlint.gradle)

application
Expand Down
2 changes: 2 additions & 0 deletions examples/projects/hibernate/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gr

# The Kotlin DataFrame Compiler plugin is the same version as the Kotlin plugin.
kotlin-dataframe = { id = "org.jetbrains.kotlin.plugin.dataframe", version.ref = "kotlin" }

kotlin-jpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be in the root libs.versions.toml as well? I saw that the Hibernate version was also specified in the root libs.versions.toml.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, please add all dependencies both in the examples as well as in the root toml file. This allows our dependency checker to see if there are any updates even if the specific example is not loaded :)

If you introduce new versions that need to be copied (you don't in your PR, but for the sake of explanation), they should also be added in the dfbuild.buildExampleProjects convention plugin

Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package org.jetbrains.kotlinx.dataframe.examples.hibernate

import jakarta.persistence.Column
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
import org.jetbrains.kotlinx.dataframe.annotations.ColumnName
import org.jetbrains.kotlinx.dataframe.annotations.DataSchema
Expand All @@ -17,9 +20,10 @@ class AlbumsEntity(
@Column(name = "AlbumId")
var albumId: Int? = null,
@Column(name = "Title", length = 160, nullable = false)
var title: String = "",
@Column(name = "ArtistId", nullable = false)
var artistId: Int = 0,
var title: String,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "ArtistId", nullable = false)
var artist: ArtistsEntity,
)

@Entity
Expand All @@ -30,7 +34,7 @@ class ArtistsEntity(
@Column(name = "ArtistId")
var artistId: Int? = null,
@Column(name = "Name", length = 120, nullable = false)
var name: String = "",
var name: String,
)

@Entity
Expand All @@ -41,9 +45,9 @@ class CustomersEntity(
@Column(name = "CustomerId")
var customerId: Int? = null,
@Column(name = "FirstName", length = 40, nullable = false)
var firstName: String = "",
var firstName: String,
@Column(name = "LastName", length = 20, nullable = false)
var lastName: String = "",
var lastName: String,
@Column(name = "Company", length = 80)
var company: String? = null,
@Column(name = "Address", length = 70)
Expand All @@ -61,7 +65,7 @@ class CustomersEntity(
@Column(name = "Fax", length = 24)
var fax: String? = null,
@Column(name = "Email", length = 60, nullable = false)
var email: String = "",
var email: String,
@Column(name = "SupportRepId")
var supportRepId: Int? = null,
)
Expand Down
Loading
Loading