Across this reproducible latency suite Storm takes five of twelve workloads outright, with no framework inside 3%. It is in the leading group on eleven and top three on all twelve, a consistency no other ORM matches. Its clearest margins are on the mapping-heavy joins.
Eight implementations run against the same database with identical schema, data, and transaction boundaries. Every result includes a real TCP round trip, and the source behind every number is open for inspection.
The workloads cover common data-access paths: point reads, joined entity hydration, projections, keyset pagination, dynamic queries, batch and dependency-ordered writes, change-aware updates and one-to-many object graphs.
Eight implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. The chart plots every workload as a multiple of the hand-written JDBC baseline, so each line traces a framework's overhead across the twelve workloads. The chart opens with the primary key lookup and then orders the workloads by how far the field spreads from JDBC, keeping the three join sizes together, so overhead grows to the right and a flat line means the framework does not follow. Lower is faster; the dashed line is JDBC itself.
The field falls into three groups. Storm is alone at the front on five workloads, with no framework within 3%: the primary-key lookup, keyset pagination and all three joins. On six more it is level with the leaders, inside a band narrower than the run-to-run noise: the projection, the dynamic query, both single-row writes and both batch writes. One workload goes to jOOQ, which takes the object graph with a single MULTISET JSON aggregate instead of repeated join rows, and Storm is second on it. Repeating the whole suite on identical hardware reproduces those three groups exactly, workload for workload.
The consistency is the part no other framework matches: Storm is in the top three on all twelve workloads and its worst placing anywhere in the suite is third. The next most consistent ORM reaches the top three on eight, and every other framework drops to fifth or lower somewhere, three of them to seventh. Only one ORM leads Storm anywhere in the suite, jOOQ on the object graph; the other five never lead on any of the twelve. A framework that is quick on the workloads it likes and mid-field on the rest is a different proposition from one with no weak workload at all.
Hydration is where the field spreads furthest: on the thousand-row join Storm carries at least 40% less per-row overhead than the closest framework, and the rest of the field pays at least 2.6x Storm's cost.
Storm's write advantage is a volume story: the batch and graph inserts run 1.4 to 1.7x faster than Hibernate, while all four writes sit in the leading group, none of them more than 1.5% off the front.
Compare within a chart; each chart is a same-session comparison. The percentage is each framework's distance to the fastest; gold marks the leading group, every framework within 3%. Where more than one framework is in it they read level rather than ranked, because the gap between them is smaller than the run-to-run noise. Hover a row for its fork range.
Load one visit by primary key. The purest round-trip test: one query, one row.
† Transaction overhead.
Three columns across three tables into a flat DTO, one hundred rows.
Insert a visit, then amend it by its generated key, in one transaction.
Read one owner, change one field, persist atomically with one UPDATE.
A filtered search assembled at runtime from a cycling set of optional predicates.
Write 20 owner-to-pet-to-visit graphs, generated keys threaded level to level.
Load the owners of a city, each with their list of pets, grouped one-to-many.
Insert 100 visits atomically and fetch their database-generated keys.
One page of 20 rows by keyset (seek) pagination, object graph materialized.
Load pets with owner and city hydrated through a single three-table join.
The same join at 100 rows. Hydration cost starts to separate the field.
The same join at 1,000 rows. Row mapping now dominates the round trip.
Each library gets the most performant solution its ecosystem documents; every entry is documented, recommended by production guidance for that library, and free of semantic penalty for its workload (the full rules are under Methodology). Storm's row is listed for symmetry: it runs unconfigured, and its fast paths are defaults rather than settings.
| Scope | Optimization | Effect |
|---|---|---|
| Everyone | Sequence-fed primary keys on the insert-target tables | No library loses JDBC batching to an identity column; Hibernate's pooled generator (allocationSize = 50) allocates ids client-side. |
| Storm | @DynamicUpdate(FIELD) on a purpose-built update shape | Writes only the changed column; the shape's lines count toward Storm's query LOC. Storm's only opt-in besides the PostgreSQL dialect module on the classpath; the multi-row RETURNING batches and the literal page size are default behavior. |
| Hibernate | @DynamicUpdate on the owner entity | Writes only the changed column. |
| Hibernate | HQL limit 20, a literal | PostgreSQL caches the generic plan for the keyset join instead of replanning it on every call. |
| jOOQ | limit(inline(20)) | The same plan-cache effect on the keyset query. |
| Ktorm | bulkInsertReturning from ktorm-support-postgresql | One multi-row INSERT … RETURNING per batch; core Ktorm would retrieve keys row by row. |
| Jimmer | setConstraintViolationTranslatable(false) | Removes the SAVEPOINT / RELEASE pair around each save command; constraint violations surface as raw exceptions, which these workloads never read. |
Result shapes are equivalent across libraries, but not every implementation does identical work. These are the differences worth knowing when reading a row.
| Where | Difference | In the numbers |
|---|---|---|
| Create then amend | Storm, Hibernate and Jimmer write the full row, since Visit declares no field-level change tracking; the change-tracking libraries write only the amended column. | Same statement count, wider UPDATE. |
| Update, Jimmer | The save writes every loaded column of the draft, not a change delta; the value change is still only the telephone. | Wider UPDATE than the others' single column. |
| Keyset, Ktorm | take(n) has no literal form, so the page size stays a bind parameter. | PostgreSQL replans the three-table join on every call, a planning pass the literal-limit implementations skip. |
| Joins and keyset, Jimmer and Exposed DAO | Fetcher and eager-loading models load associations in follow-up batched queries. | Extra round trips by design; query counts are listed with each workload. |
| Batch insert, Hibernate | Ids are allocated client-side from the pooled sequence; no keys are requested from the driver. | About two nextval calls per hundred rows; the rows go out as one JDBC batch of single-row statements. |
Numbers without code invite tuned-benchmark suspicion, so the counts below, and the workloads that follow, show exactly what each library runs, trimmed of harness plumbing. The full sources for all eight implementations are in the benchmark repository.
The entity or table definition file by the same counting rule. This is the model code developers write and maintain.
JDBC and jOOQ have no hand-written model: JDBC maps rows by hand and jOOQ generates its table classes, so their cost appears in the suite total above instead.
The workload file for all twelve workloads, including its row mapping, counted as non-blank, non-comment, non-import, non-package lines. Generated code is excluded for the two libraries that use it, Storm's metamodel and jOOQ's table classes, and result types shared across every implementation are excluded on all sides. Purpose-built shapes defined on top of a library's regular entity to speed a workload count toward its query lines.
Storm implements all twelve workloads in the fewest lines; every other implementation needs 10% to 145% more. The counts come from scripts/count_loc.py in the benchmark repository, so they can be reverified against any checkout. Beyond the line count, the labels show what a low number can leave unsaid: hand-mapped rows are written and maintained by hand, and string queries are not compile-checked.
LOC is indicative, not conclusive: twelve workloads over a five-table schema is a small corpus, and a different application profile shifts the counts. It is presented as an illustration of these benchmark implementations, not as a universal measure of framework complexity.
Each workload shows Storm's implementation. Pick another library from the selector to compare it, or toggle Show SQL for the exact statement on the wire.
Storm's model is plain data classes. Nullability, keys and relations live in the type: @FK val owner: Owner hydrates through a join, Ref<PetType> stays a lazy reference until asked. No proxies, no session lifecycle, nothing to configure. Compare it against the JPA entities, table objects and interfaces the other libraries declare for the same five tables.
data class City( @PK val id: Long = 0, val name: String, ) : Entity<Long> data class Owner( @PK val id: Long = 0, val firstName: String, val lastName: String, val address: String, val telephone: String, @FK val city: City, ) : Entity<Long> data class Pet( @PK val id: Long = 0, val name: String, val birthDate: LocalDate, @FK val type: Ref<PetType>, @FK val owner: Owner, ) : Entity<Long>
// No ORM model. Rows are hand-mapped into plain records: record City(long id, String name) {} record Owner(long id, String firstName, String lastName, String address, String telephone, City city) {} record Pet(long id, String name, LocalDate birthDate, long typeId, Owner owner) {}
// Private fields, field access. Getters/setters added only where the app calls them. @Entity @Table(name = "city") class City { @Id @GeneratedValue(strategy = IDENTITY) Long id; @Column(name = "name") String name; } @Entity @Table(name = "owner") @DynamicUpdate class Owner { @Id @GeneratedValue(strategy = IDENTITY) Long id; @Column(name = "first_name") String firstName; @Column(name = "last_name") String lastName; @Column(name = "address") String address; @Column(name = "telephone") String telephone; @ManyToOne(fetch = LAZY) @JoinColumn(name = "city_id") City city; @OneToMany(mappedBy = "owner") List<Pet> pets; } @Entity @Table(name = "pet") class Pet { @Id @GeneratedValue(strategy = IDENTITY) Long id; @Column(name = "name") String name; @Column(name = "birth_date") LocalDate birthDate; @ManyToOne(fetch = LAZY) @JoinColumn(name = "type_id") PetType type; @ManyToOne(fetch = LAZY) @JoinColumn(name = "owner_id") Owner owner; }
// jOOQ generates the table classes from the schema; there is no entity model. // You still write the result records it maps into, shared here with JDBC: record City(long id, String name) {} record Owner(long id, String firstName, String lastName, String address, String telephone, City city) {} record Pet(long id, String name, LocalDate birthDate, long typeId, Owner owner) {}
object Cities : Table("city") { val id = long("id").autoIncrement() val name = varchar("name", 100) override val primaryKey = PrimaryKey(id) } object Owners : Table("owner") { val id = long("id").autoIncrement() val firstName = varchar("first_name", 50) val lastName = varchar("last_name", 50) val address = varchar("address", 120) val telephone = varchar("telephone", 20) val cityId = long("city_id").references(Cities.id) override val primaryKey = PrimaryKey(id) } object Pets : Table("pet") { val id = long("id").autoIncrement() val name = varchar("name", 50) val birthDate = date("birth_date") val typeId = long("type_id").references(PetTypes.id) val ownerId = long("owner_id").references(Owners.id) override val primaryKey = PrimaryKey(id) }
object Cities : LongIdTable("city") { val name = varchar("name", 100) } object Owners : LongIdTable("owner") { val firstName = varchar("first_name", 50) val lastName = varchar("last_name", 50) val address = varchar("address", 120) val telephone = varchar("telephone", 20) val cityId = reference("city_id", Cities) } object Pets : LongIdTable("pet") { val name = varchar("name", 50) val birthDate = date("birth_date") val typeId = reference("type_id", PetTypes) val ownerId = reference("owner_id", Owners) } class CityDao(id: EntityID<Long>) : LongEntity(id) { companion object : LongEntityClass<CityDao>(Cities) var name by Cities.name } class OwnerDao(id: EntityID<Long>) : LongEntity(id) { companion object : LongEntityClass<OwnerDao>(Owners) var firstName by Owners.firstName var lastName by Owners.lastName var address by Owners.address var telephone by Owners.telephone var city by CityDao referencedOn Owners.cityId val pets by PetDao referrersOn Pets.ownerId } class PetDao(id: EntityID<Long>) : LongEntity(id) { companion object : LongEntityClass<PetDao>(Pets) var name by Pets.name var birthDate by Pets.birthDate var typeId by Pets.typeId var owner by OwnerDao referencedOn Pets.ownerId }
interface Owner : Entity<Owner> { companion object : Entity.Factory<Owner>() val id: Long var firstName: String var lastName: String var address: String var telephone: String var city: City } object Owners : Table<Owner>("owner") { val id = long("id").primaryKey().bindTo { it.id } val firstName = varchar("first_name").bindTo { it.firstName } // … lastName, address and telephone bindings … val cityId = long("city_id").references(Cities) { it.city } }
@Entity @Table(name = "city") interface City { @Id long id(); String name(); } @Entity @Table(name = "owner") interface Owner { @Id long id(); String firstName(); String lastName(); String address(); String telephone(); @ManyToOne @JoinColumn(name = "city_id") City city(); @OneToMany(mappedBy = "owner") List<Pet> pets(); } @Entity @Table(name = "pet") interface Pet { @Id long id(); String name(); LocalDate birthDate(); @ManyToOne @JoinColumn(name = "type_id") PetType type(); @ManyToOne @JoinColumn(name = "owner_id") Owner owner(); }
Load one visit by its primary key: one query, one row, the purest round-trip test. The pet reference stays lazy for every implementation (a Ref in Storm, a proxy or plain id elsewhere), so no join runs and the wire round trip dominates the score. What separates libraries here is per-call machinery: building the statement, binding one value and mapping one row.
val visit = visits.getById(id)
try (var ps = connection.prepareStatement( "SELECT id, pet_id, visit_date, description FROM visit WHERE id = ?")) { ps.setLong(1, id); try (var rs = ps.executeQuery()) { rs.next(); return new Visit(rs.getLong(1), rs.getLong(2), rs.getObject(3, LocalDate.class), rs.getString(4)); } }
return sessionFactory.fromSession(session -> session.find(Visit.class, id));
return ctx.select(VISIT.ID, VISIT.PET_ID, VISIT.VISIT_DATE, VISIT.DESCRIPTION) .from(VISIT) .where(VISIT.ID.eq(id)) .fetchOne(Records.mapping(Visit::new));
transaction(database) { Visits.selectAll().where { Visits.id eq id }.single().toVisit() }
transaction(database) { VisitDao.findById(id)!!.toVisit() }
database.sequenceOf(Visits).find { it.id eq id }!!
return sqlClient.getEntities().findById(Visit.class, id);
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ? LIMIT ? -- find appends a bound limit
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
A template picks three columns across the graph; the metamodel keeps every path compile-checked.
data class PetRow(val petName: String, val ownerLastName: String, val cityName: String) val rows = orm.selectFrom<Pet, PetRow> { "${Pet_.name}, ${Pet_.owner.lastName}, ${Pet_.owner.city.name}" } .where(Pet_.owner.city.id eq cityId) .resultList
try (var ps = connection.prepareStatement( "SELECT p.name, o.last_name, c.name FROM pet p" + " JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id" + " WHERE o.city_id = ?")) { ps.setLong(1, cityId); try (var rs = ps.executeQuery()) { List<PetRow> rows = new ArrayList<>(); while (rs.next()) rows.add(new PetRow(rs.getString(1), rs.getString(2), rs.getString(3))); return rows; } }
return sessionFactory.fromSession(session -> session .createSelectionQuery( "select p.name, o.lastName, c.name from Pet p join p.owner o join o.city c where c.id = :cityId", PetRow.class) .setParameter("cityId", cityId) .getResultList());
return ctx.select(PET.NAME, OWNER.LAST_NAME, CITY.NAME) .from(PET) .join(OWNER).on(PET.OWNER_ID.eq(OWNER.ID)) .join(CITY).on(OWNER.CITY_ID.eq(CITY.ID)) .where(OWNER.CITY_ID.eq(cityId)) .fetch(Records.mapping(PetRow::new));
transaction(database) { (Pets innerJoin Owners innerJoin Cities) .select(Pets.name, Owners.lastName, Cities.name) .where { Owners.cityId eq cityId } .map { PetRow(it[Pets.name], it[Owners.lastName], it[Cities.name]) } }
// Exposed DAO drops to the same DSL query for projections transaction(database) { (Pets innerJoin Owners innerJoin Cities) .select(Pets.name, Owners.lastName, Cities.name) .where { Owners.cityId eq EntityID(cityId, Cities) } .map { PetRow(it[Pets.name], it[Owners.lastName], it[Cities.name]) } }
database.from(Pets) .innerJoin(Owners, on = Pets.ownerId eq Owners.id) .innerJoin(Cities, on = Owners.cityId eq Cities.id) .select(Pets.name, Owners.lastName, Cities.name) .where { Owners.cityId eq cityId } .map { PetRow(it[Pets.name]!!, it[Owners.lastName]!!, it[Cities.name]!!) }
PetTable table = PetTable.$; return sqlClient.createQuery(table) .where(table.owner().city().id().eq(cityId)) .select(table.name(), table.owner().lastName(), table.owner().city().name()) .execute();
SELECT p.name, o.last_name, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE o.city_id = ?
One transaction, two dependent statements: insert a visit, then amend it using the generated key. Storm returns the key from the insert and updates a copy of the immutable record; the entity libraries amend a managed instance and let change tracking write it.
return transaction { val visit = Visit(pet = refById<Pet>(petId), visitDate = date, description = text) val id = visits.insertAndFetchId(visit) visits.update(visit.copy(id = id, description = "${visit.description} (rechecked)")) id }
try (var insert = connection.prepareStatement( "INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?)", Statement.RETURN_GENERATED_KEYS)) { // bind, executeUpdate, read the key from getGeneratedKeys() } try (var update = connection.prepareStatement("UPDATE visit SET description = ? WHERE id = ?")) { // bind the amended description and the key, executeUpdate, commit }
return sessionFactory.fromTransaction(session -> { Visit visit = new Visit(session.getReference(Pet.class, petId), date, text); session.persist(visit); session.flush(); // the persisted instance is managed; amend it and let dirty checking flush visit.setDescription(visit.getDescription() + " (rechecked)"); return visit.getId(); });
Long id = c.insertInto(VISIT, VISIT.PET_ID, VISIT.VISIT_DATE, VISIT.DESCRIPTION) .values(petId, date, text) .returning(VISIT.ID).fetchOne().getId(); c.update(VISIT) .set(VISIT.DESCRIPTION, text + " (rechecked)") .where(VISIT.ID.eq(id)) .execute();
val inserted = Visits.insert { it[Visits.petId] = petId; it[Visits.visitDate] = date; it[Visits.description] = text } val id = inserted[Visits.id] // the insert result carries the generated id Visits.update({ Visits.id eq id }) { it[Visits.description] = "$text (rechecked)" }
val dao = VisitDao.new { petId = …; visitDate = date; description = text } val id = dao.id.value // reading the id forces the pending insert to flush dao.description = dao.description + " (rechecked)"
val visit = Visit { petId = pid; visitDate = date; description = text } database.sequenceOf(Visits).add(visit) // populates the generated id visit.description = "${visit.description} (rechecked)" visit.flushChanges()
Visit saved = sqlClient.getEntities().saveCommand(visit) .setMode(SaveMode.INSERT_ONLY).execute(connection).getModifiedEntity(); Visit updated = VisitDraft.$.produce(saved, draft -> draft.setDescription(saved.description() + " (rechecked)")); sqlClient.getEntities().saveCommand(updated).setMode(SaveMode.UPDATE_ONLY).execute(connection);
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) RETURNING id UPDATE visit SET pet_id = ?, visit_date = ?, description = ? -- the full row: Visit declares no field-level update tracking WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- the generated key comes back through RETURNING UPDATE visit SET description = ? -- only the amended column is written WHERE id = ?
SELECT nextval('visit_seq') -- at most once per 50 inserts: the pooled optimizer allocates client-side INSERT INTO visit (description, pet_id, visit_date, id) VALUES (?, ?, ?, ?) -- id assigned client-side UPDATE visit SET description = ?, pet_id = ?, visit_date = ? -- dirty checking writes the full row: Visit has no @DynamicUpdate WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- the generated key comes back through RETURNING UPDATE visit SET description = ? -- only the amended column is written WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- the generated key comes back through RETURNING UPDATE visit SET description = ? -- only the amended column is written WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- the generated key comes back through RETURNING UPDATE visit SET description = ? -- only the amended column is written WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- the generated key comes back through RETURNING UPDATE visit SET description = ? -- only the amended column is written WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) RETURNING id UPDATE visit SET pet_id = ?, visit_date = ?, description = ? -- the full row: Visit declares no field-level update tracking WHERE id = ?
Storm's regular Owner is an aggregate: reading one loads its city through a join. Every other library declares that association lazy and reads the owner row alone, so to keep the read side of this workload identical for everyone, the benchmark uses a dedicated shape of the same table where city stays a lazy Ref. That shape is one record; declaring it is Storm's equivalent of the FetchType.LAZY the others put on their entities, and its ten lines are counted against Storm in the Queries LOC table above. On the write side, @DynamicUpdate(FIELD) writes only the column that changed. Entities are immutable; an update is a copy.
@DbTable("owner") // in-place optimization for writing: city as lazy ref @DynamicUpdate(UpdateMode.FIELD) data class OwnerCityRef( @PK val id: Long = 0, val firstName: String, val lastName: String, val address: String, val telephone: String, @FK val city: Ref<City>, ) : Entity<Long> transaction { val owner = owners.getById(id) owners.update(owner.copy(telephone = newTelephone)) // only the changed column is written }
connection.setAutoCommit(false); String phone; try (var ps = connection.prepareStatement( "SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = ?")) { ps.setLong(1, id); var rs = ps.executeQuery(); rs.next(); phone = Params.toggleTelephone(rs.getString(5)); owner = new Owner(rs.getLong(1), ..., phone, null); } try (var ps = connection.prepareStatement("UPDATE owner SET telephone = ? WHERE id = ?")) { ps.setString(1, phone); ps.setLong(2, id); ps.executeUpdate(); } connection.commit();
@Entity @Table(name = "owner") @DynamicUpdate // write only the changed columns class Owner { @Id @GeneratedValue(strategy = IDENTITY) private Long id; @Column(name = "first_name") private String firstName; @Column(name = "last_name") private String lastName; @Column(name = "address") private String address; @Column(name = "telephone") private String telephone; @ManyToOne(fetch = LAZY) @JoinColumn(name = "city_id") private City city; // lazy: city not read @OneToMany(mappedBy = "owner") private List<Pet> pets; public String getTelephone() { return telephone; } public void setTelephone(String telephone) { this.telephone = telephone; } // Hibernate maps via field access; getId()/getPets() added where the app needs them } sessionFactory.fromTransaction(session -> { Owner owner = session.find(Owner.class, id); owner.setTelephone(Params.toggleTelephone(owner.getTelephone())); return owner; // dirty-checking flushes only telephone });
return ctx.transactionResult(tx -> { var record = DSL.using(tx).fetchOne(OWNER, OWNER.ID.eq(id)); record.setTelephone(Params.toggleTelephone(record.getTelephone())); record.store(); // UpdatableRecord.store() updates only the changed field return record.getId(); });
transaction(database) { val row = Owners.selectAll().where { Owners.id eq id }.single() val phone = Params.toggleTelephone(row[Owners.telephone]) Owners.update({ Owners.id eq id }) { it[Owners.telephone] = phone } }
transaction(database) { val dao = OwnerDao.findById(id) ?: error("owner not found") dao.telephone = Params.toggleTelephone(dao.telephone) // dirty tracking flushes only telephone }
database.useTransaction { // withReferences = false keeps the read lazy: owner columns only, no city join val owner = database.sequenceOf(Owners, withReferences = false).find { it.id eq id }!! owner.telephone = Params.toggleTelephone(owner.telephone) owner.flushChanges() // dirty tracking writes only the changed column }
Owner owner = sqlClient.createQuery(OwnerTable.$) .where(OwnerTable.$.id().eq(id)).select(OwnerTable.$).execute(connection).getFirst(); Owner updated = OwnerDraft.$.produce(owner, d -> d.setTelephone(Params.toggleTelephone(owner.telephone()))); sqlClient.getEntities().saveCommand(updated) .setMode(SaveMode.UPDATE_ONLY) // writes every loaded column of the draft .execute(connection);
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET telephone = ? WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET telephone = ? WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET telephone = ? WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET telephone = ? WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET telephone = ? WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET telephone = ? WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET telephone = ? WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ? UPDATE owner SET first_name = ?, last_name = ?, address = ?, telephone = ?, city_id = ? WHERE id = ? -- the save writes every loaded column of the draft; only the telephone value changed
A filtered search assembled at runtime from optional predicates. Storm composes type-safe predicates with and and keeps the projection a flat row type. JDBC and Hibernate grow the query string; jOOQ, Exposed, Ktorm and Jimmer compose typed conditions of their own. The SQL carries only the predicates that are active.
var predicate: PredicateBuilder<Pet, *, *> = Pet_.owner.city.id eq filter.cityId if (filter.byDate) predicate = predicate and (Pet_.birthDate greaterEq filter.minBirthDate) if (filter.byType) predicate = predicate and (Pet_.type eq refById<PetType>(filter.typeId)) return pets.select<PetRow, _, _> { "${Pet_.name}, ${Pet_.owner.lastName}, ${Pet_.owner.city.name}" } .where(predicate) .resultList
var sql = new StringBuilder("SELECT p.name, o.last_name, c.name FROM pet p JOIN … WHERE o.city_id = ?"); if (filter.byDate()) sql.append(" AND p.birth_date >= ?"); if (filter.byType()) sql.append(" AND p.type_id = ?"); // bind the parameters in the same order the string grew, then map each row
var hql = new StringBuilder("select p.name, o.lastName, c.name from Pet p join p.owner o join o.city c where o.city.id = :cityId"); if (filter.byDate()) hql.append(" and p.birthDate >= :minDate"); if (filter.byType()) hql.append(" and p.type.id = :typeId"); // create the query, set the parameters that are present, getResultList()
Condition condition = OWNER.CITY_ID.eq(filter.cityId()); if (filter.byDate()) condition = condition.and(PET.BIRTH_DATE.ge(filter.minBirthDate())); if (filter.byType()) condition = condition.and(PET.TYPE_ID.eq(filter.typeId())); return ctx.select(PET.NAME, OWNER.LAST_NAME, CITY.NAME) .from(PET).join(OWNER).on(…).join(CITY).on(…) .where(condition) .fetch(Records.mapping(PetRow::new));
(Pets innerJoin Owners innerJoin Cities) .select(Pets.name, Owners.lastName, Cities.name) .where { var condition: Op<Boolean> = Owners.cityId eq filter.cityId if (filter.byDate) condition = condition and (Pets.birthDate greaterEq filter.minBirthDate) if (filter.byType) condition = condition and (Pets.typeId eq filter.typeId) condition } .map { PetRow(it[Pets.name], it[Owners.lastName], it[Cities.name]) }
// The DAO layer drops to the DSL for flat projections, as DAO applications do; // the implementation matches Exposed with EntityID-wrapped key comparisons.
val conditions = ArrayList<ColumnDeclaring<Boolean>>() conditions += Owners.cityId eq filter.cityId if (filter.byDate) conditions += Pets.birthDate greaterEq filter.minBirthDate if (filter.byType) conditions += Pets.typeId eq filter.typeId database.from(Pets).innerJoin(Owners, on = …).innerJoin(Cities, on = …) .select(Pets.name, Owners.lastName, Cities.name) .where { conditions.reduce { a, b -> a and b } } .map { PetRow(it[Pets.name]!!, it[Owners.lastName]!!, it[Cities.name]!!) }
// Jimmer ignores null predicates, its idiom for dynamic queries. return sqlClient.createQuery(table) .where(table.owner().city().id().eq(filter.cityId())) .where(filter.byDate() ? table.birthDate().ge(filter.minBirthDate()) : null) .where(filter.byType() ? table.type().id().eq(filter.typeId()) : null) .select(table.name(), table.owner().lastName(), table.owner().city().name()) .execute();
SELECT p.name, o.last_name, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE o.city_id = ? AND p.birth_date >= ? AND p.type_id = ? -- the optional predicates appear only when the filter sets them
Twenty owner-to-pet-to-visit graphs written in one transaction, generated keys propagated from parent to child. Storm receives only the visits: the write set discovers the unsaved pets and owners through the refs, orders the dependency levels and writes one multi-row statement per type. The workload returns the generated visit ids, the contract of a create endpoint. Every other implementation except Hibernate orders the levels itself.
val visits = graphs.map { g -> val owner = Owner(firstName = …, lastName = …, address = …, telephone = …, city = City(id = g.cityId, name = "")) val pet = Pet(name = …, birthDate = …, type = refById<PetType>(g.typeId), owner = owner) Visit(pet = Ref.of(pet), visitDate = …, description = …) } // Only the visits are passed: the write set discovers the unsaved pets and owners through the // refs, writes one multi-row statement per type per dependency level and propagates the keys. return transaction { orm.writeSet().insertAndFetchIds(visits) }
List<Long> ownerIds = multiRowInsertReturningKeys(connection, "owner", …); List<Long> petIds = multiRowInsertReturningKeys(connection, "pet", …); // threads ownerIds List<Long> visitIds = multiRowInsertReturningKeys(connection, "visit", …); // threads petIds // each level is one INSERT … VALUES (…),(…) RETURNING id; the caller orders the levels
for (var g : graphs) { Owner owner = new Owner(…, session.getReference(City.class, g.cityId())); Pet pet = new Pet(…, owner); owner.getPets().add(pet); Visit visit = new Visit(pet, …); pet.getVisits().add(visit); // cascade persist walks owner -> pets -> visits, ordering and batching the inserts session.persist(owner); } session.flush();
var ownerInsert = c.insertInto(OWNER, OWNER.FIRST_NAME, …); for (var g : graphs) ownerInsert = ownerInsert.values(…); List<Long> ownerIds = ownerInsert.returning(OWNER.ID).fetch().map(r -> r.get(OWNER.ID)); // same shape for pets (threading ownerIds) and visits (threading petIds): // three multi-row INSERT … RETURNING statements, ordered by the caller
val ownerIds = Owners.batchInsert(graphs, shouldReturnGeneratedValues = true) { g -> this[Owners.firstName] = …; this[Owners.cityId] = g.cityId }.map { it[Owners.id] } // same shape for pets (threading ownerIds) and visits (threading petIds)
// Identical to Exposed: three batchInsert calls with generated values returned, // with EntityID-wrapped keys threaded between the levels.
// bulkInsertReturning (ktorm-support-postgresql): one multi-row INSERT … RETURNING per level. val ownerIds = database.bulkInsertReturning(Owners, Owners.id) { for (g in graphs) { item { set(it.firstName, …); set(it.cityId, g.cityId) } } } // same shape for pets (threading ownerIds) and visits (threading petIds)
List<Long> ownerIds = saveEntitiesReturningIds(connection, ownerDrafts, Owner::id); List<Long> petIds = saveEntitiesReturningIds(connection, petDrafts, Pet::id); // threads ownerIds List<Long> visitIds = saveEntitiesReturningIds(connection, visitDrafts, Visit::id); // three saveEntities commands, each level threading the previous ids
INSERT INTO owner (first_name, last_name, address, telephone, city_id) VALUES (?, ?, ?, ?, ?), … -- 20 value rows RETURNING "id" INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id" INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id" -- no re-read: the workload returns the generated visit ids
INSERT INTO owner (first_name, last_name, address, telephone, city_id) VALUES (?, ?, ?, ?, ?), … -- 20 value rows RETURNING "id" INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id" INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id" -- no re-read: the workload returns the generated visit ids
SELECT nextval('owner_seq') -- pooled sequences allocate the ids client-side INSERT INTO owner (…, id) VALUES (?, …) -- batched x20 per type, ordered by -- ORDER_INSERTS: owners, then pets, then visits
INSERT INTO owner (first_name, last_name, address, telephone, city_id) VALUES (?, ?, ?, ?, ?), … -- 20 value rows RETURNING "id" INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id" INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id" -- no re-read: the workload returns the generated visit ids
INSERT INTO owner (…) VALUES (?, …) RETURNING * -- one row per statement, INSERT INTO pet (…) VALUES (?, …) RETURNING * -- one JDBC batch of 20 per level INSERT INTO visit (…) VALUES (?, …) RETURNING *
INSERT INTO owner (…) VALUES (?, …) RETURNING * -- one row per statement, INSERT INTO pet (…) VALUES (?, …) RETURNING * -- one JDBC batch of 20 per level INSERT INTO visit (…) VALUES (?, …) RETURNING *
INSERT INTO owner (first_name, last_name, address, telephone, city_id) VALUES (?, ?, ?, ?, ?), … -- 20 value rows RETURNING "id" INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id" INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id" -- no re-read: the workload returns the generated visit ids
INSERT INTO owner (…) VALUES (?, …) RETURNING id -- one row per statement, INSERT INTO pet (…) VALUES (?, …) RETURNING id -- one JDBC batch of 20 per level INSERT INTO visit (…) VALUES (?, …) RETURNING id
Load the owners of a city, each with their list of pets. Storm, JDBC, Exposed and Ktorm run one three-table join and group the rows during hydration; in Storm, repeated owners deduplicate to the same instance, so grouping is an identity operation rather than a hash of every field. jOOQ nests the pets server-side with MULTISET, Hibernate collapses its join fetch cartesian with distinct, and Exposed DAO and Jimmer load the pets in a follow-up batched query.
val result = pets.select() .where(Pet_.owner.city.id eq cityId) .orderBy(Pet_.owner) .resultGroupedBy(Pet_.owner) .map { (owner, pets) -> OwnerWithPets(owner, pets) }
Map<Long, Owner> owners = new LinkedHashMap<>(); Map<Long, List<Pet>> pets = new LinkedHashMap<>(); while (rs.next()) { long ownerId = rs.getLong(1); Owner owner = owners.get(ownerId); if (owner == null) { owner = new Owner(ownerId, ..., new City(rs.getLong(6), rs.getString(7))); owners.put(ownerId, owner); pets.put(ownerId, new ArrayList<>()); } pets.get(ownerId).add(new Pet(rs.getLong(8), ..., owner)); } // then zip owners + pets into List<OwnerWithPets>
List<Owner> owners = sessionFactory.fromSession(session -> session .createSelectionQuery( "select distinct o from Owner o join fetch o.pets join fetch o.city where o.city.id = :cityId", Owner.class) .setParameter("cityId", cityId) .getResultList());
List<OwnerWithPets> result = ctx.select( OWNER.ID, OWNER.FIRST_NAME, OWNER.LAST_NAME, OWNER.ADDRESS, OWNER.TELEPHONE, CITY.ID, CITY.NAME, multiset( select(PET.ID, PET.NAME, PET.BIRTH_DATE, PET.TYPE_ID) .from(PET) .where(PET.OWNER_ID.eq(OWNER.ID)))) .from(OWNER) .join(CITY).on(OWNER.CITY_ID.eq(CITY.ID)) .where(OWNER.CITY_ID.eq(cityId)) .orderBy(OWNER.ID) .fetch(record -> { Owner owner = new Owner(record.value1(), record.value2(), record.value3(), record.value4(), record.value5(), new City(record.value6(), record.value7())); List<Pet> pets = record.value8() .map(pet -> new Pet(pet.value1(), pet.value2(), pet.value3(), pet.value4(), owner)); return new OwnerWithPets(owner, pets); });
transaction(database) { (Owners innerJoin Cities innerJoin Pets) .selectAll() .where { Owners.cityId eq cityId } .orderBy(Owners.id) .groupIntoOwners() // in-memory LinkedHashMap grouping }
transaction(database) { OwnerDao.find { Owners.cityId eq EntityID(cityId, Cities) } .orderBy(Owners.id to SortOrder.ASC) .with(OwnerDao::city, OwnerDao::pets) // pets = batched SELECT ... WHERE owner_id IN (...) .map { OwnerWithPets(it.toOwner(), it.pets.map { p -> p.toPet() }) } }
database.from(Owners) .innerJoin(Cities, on = Owners.cityId eq Cities.id) .innerJoin(Pets, on = Pets.ownerId eq Owners.id) .select() .where { Owners.cityId eq cityId } .orderBy(Owners.id.asc()) .map { Pets.createEntity(it) } .groupIntoOwners() // in-memory LinkedHashMap grouping
OwnerTable table = OwnerTable.$; return sqlClient.createQuery(table) .where(table.city().id().eq(cityId)) .orderBy(table.id().asc()) .select(table.fetch( OwnerFetcher.$.allScalarFields() .city(CityFetcher.$.allScalarFields()) .pets(PetFetcher.$.allScalarFields()))) // pets = batched WHERE owner_id IN (...) .execute();
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE o.city_id = ? ORDER BY p.owner_id
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE o.city_id = ? ORDER BY p.owner_id
SELECT DISTINCT o.id, o.first_name, o.last_name, o.address, o.telephone, p.owner_id, p.id, p.birth_date, p.name, p.type_id, c.id, c.name FROM owner o JOIN pet p ON o.id = p.owner_id JOIN city c ON c.id = o.city_id WHERE c.id = ? -- DISTINCT collapses the owner x pet cartesian, no ORDER BY
SELECT owner.id, owner.first_name, owner.last_name, owner.address, owner.telephone, city.id, city.name, (SELECT jsonb_agg(jsonb_build_array(p.id, p.name, p.birth_date, p.type_id)) FROM pet p WHERE p.owner_id = owner.id) AS pets -- correlated MULTISET, no pet join FROM owner JOIN city ON owner.city_id = city.id WHERE owner.city_id = ? ORDER BY owner.id
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE o.city_id = ? ORDER BY p.owner_id
-- 1) main owners query SELECT owner.id, ..., owner.city_id FROM owner WHERE owner.city_id = ? ORDER BY owner.id -- 2) the city (one per workload call) SELECT city.id, city.name FROM city WHERE city.id = ? -- 3) batched pets (the collection) SELECT pet.id, pet.name, pet.birth_date, pet.type_id, pet.owner_id FROM pet WHERE pet.owner_id IN (?, ?, ...)
SELECT * -- the DSL join selects every column of the three tables FROM owner INNER JOIN city ON owner.city_id = city.id INNER JOIN pet ON pet.owner_id = owner.id WHERE owner.city_id = ? ORDER BY owner.id
-- 1) main owners query (city().id() folds to owner.city_id) SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.city_id = ? ORDER BY o.id -- 2) the city (one per workload call) SELECT c.id, c.name FROM city c WHERE c.id = ? -- 3) batched pets collection (the OneToMany), chunked into several ANY batches SELECT p.owner_id, p.id, p.name, p.birth_date, p.type_id FROM pet p WHERE p.owner_id = ANY(?)
Insert one hundred visits in a transaction and return their generated keys. Storm emits a single multi-row INSERT carrying all hundred rows and reads the keys from its RETURNING clause; jOOQ builds the same statement from a hundred values() tuples, and Ktorm matches it through bulkInsertReturning from its PostgreSQL support module. Exposed, Exposed DAO and Jimmer read the keys back from a JDBC batch of single-row statements, and Hibernate assigns its ids client-side from the pooled sequence and batches the same way, asking for no keys back.
val ids = transaction { visits.insertAndFetchIds(newVisits) // 100 visits, one multi-row INSERT returning the keys }
List<Long> ids = multiRowInsertReturningKeys(connection, "visit", "pet_id, visit_date, description", 3, BATCH_SIZE, (ps, base, i) -> { ps.setLong(base + 1, ...); ps.setObject(base + 2, ...); ps.setString(base + 3, ...); }); // one INSERT ... VALUES (...),(...) RETURNING id: the driver disables batch rewriting // when generated keys are requested, so executeBatch cannot express this technique
sessionFactory.fromTransaction(session -> { for (int i = 0; i < BATCH_SIZE; i++) { Pet pet = session.getReference(Pet.class, ...); // proxy, no SELECT session.persist(new Visit(pet, ..., ...)); } session.flush(); // ids pre-fetched from visit_seq return visits.stream().map(Visit::getId).toList(); });
var insert = ctx.insertInto(VISIT, VISIT.PET_ID, VISIT.VISIT_DATE, VISIT.DESCRIPTION); for (int i = 0; i < BATCH_SIZE; i++) { insert = insert.values(..., ..., ...); // one VALUES tuple appended per row } return insert.returning(VISIT.ID).fetch().map(r -> r.get(VISIT.ID));
Visits.batchInsert(0 until BATCH_SIZE, shouldReturnGeneratedValues = true) { i -> this[Visits.petId] = ... this[Visits.visitDate] = ... this[Visits.description] = ... }.map { it[Visits.id] } // Exposed batchInsert = JDBC addBatch, not multi-row VALUES
val daos = (0 until BATCH_SIZE).map { VisitDao.new { petId = ...; visitDate = ...; description = ... } } daos.map { it.id.value } // reading ids flushes the pending inserts as a batch
database.useTransaction { // bulkInsertReturning (ktorm-support-postgresql): one multi-row INSERT … RETURNING database.bulkInsertReturning(Visits, Visits.id) { for (i in 0 until BATCH_SIZE) { item { set(it.petId, …); set(it.visitDate, …); set(it.description, …) } } } }
List<Visit> drafts = ...; // 100x VisitDraft.$.produce(d -> { d.setPet(makeIdOnly(..)); .. }) sqlClient.getEntities() .saveEntitiesCommand(drafts) .setMode(SaveMode.INSERT_ONLY) .execute(connection); // JDBC batch, keys from RETURNING
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?), (?, ?, ?), ... -- 100 tuples, one statement RETURNING id
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?), (?, ?, ?), ... -- 100 tuples, one statement RETURNING id
SELECT nextval('visit_seq') -- pooled sequence, ~2 calls for 100 rows INSERT INTO visit (pet_id, visit_date, description, id) VALUES (?, ?, ?, ?) -- batched x100, ids assigned client-side
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?), (?, ?, ?), ... -- 100 tuples, one statement RETURNING id
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- one row per statement, sent as a single JDBC batch of 100 RETURNING * -- appended by the driver to serve getGeneratedKeys
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- one row per statement, sent as a single JDBC batch of 100 RETURNING * -- appended by the driver to serve getGeneratedKeys
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?), (?, ?, ?), ... -- 100 tuples, one statement RETURNING id
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?) -- one row per statement, sent as a single JDBC batch of 100 RETURNING id -- keys collected per batched statement
One page of twenty rows through seek pagination: filter past the cursor, order by the key, stop after a page. Storm's scroll terminal fetches one extra row to detect whether a next page exists, and inlines the page size as a literal. That literal matters more than it looks: the execution plan is the same either way, but when the page size arrives as a bind parameter PostgreSQL never adopts a cached generic plan and replans the three-table join on every call, which costs about as much as executing it. Every implementation that can express a literal page size does: Storm and JDBC by construction, Exposed's limit, Hibernate's HQL limit clause, jOOQ's DSL.inline. Ktorm's take has no literal form and pays that planning pass, and the fetcher libraries load owner and city in follow-up batched queries, paying round trips instead.
// Seek past the cursor, one page deep; Pet, Owner and City hydrate from one query. val page = pets.scroll(Scrollable.of(Pet_.id, cursor, PAGE_SIZE)).content
try (var ps = connection.prepareStatement(""" SELECT p.id, p.name, … , c.id, c.name FROM pet p JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id WHERE p.id > ? ORDER BY p.id LIMIT %d""".formatted(PAGE_SIZE))) { // literal LIMIT: PostgreSQL settles on a cached plan ps.setLong(1, cursor); // execute and map each row into Pet, Owner and City by hand }
// The HQL limit clause inlines the constant page size, so PostgreSQL caches the generic plan; // setMaxResults would bind it and force a fresh planning pass on every call. return sessionFactory.fromSession(session -> session .createSelectionQuery( "from Pet p join fetch p.owner o join fetch o.city where p.id > :cursor order by p.id limit 20", Pet.class) .setParameter("cursor", cursor) .getResultList());
return ctx.select(PET.ID, PET.NAME, PET.BIRTH_DATE, PET.TYPE_ID, row(OWNER.ID, … , row(CITY.ID, CITY.NAME).mapping(City::new)).mapping(Owner::new)) .from(PET).join(OWNER).on(…).join(CITY).on(…) .orderBy(PET.ID).seek(cursor) .limit(inline(PAGE_SIZE)) // inlined constant: PostgreSQL caches the generic plan .fetch(Records.mapping(Pet::new));
transaction(database) { (Pets innerJoin Owners innerJoin Cities) .selectAll() .where { Pets.id greater cursor } .orderBy(Pets.id) .limit(PAGE_SIZE) .map { it.toPet() } }
transaction(database) { PetDao.wrapRows(Pets.selectAll() .where { Pets.id greater cursor } .orderBy(Pets.id to SortOrder.ASC).limit(PAGE_SIZE)) .with(PetDao::owner, OwnerDao::city) // eager-loads in batched queries .map { it.toPet() } }
database.sequenceOf(Pets) .filter { Pets.id greater cursor } .sortedBy { Pets.id } .take(PAGE_SIZE) .toList() // reference bindings join owner and city
return sqlClient.createQuery(table) .where(table.id().gt(cursor)) .orderBy(table.id().asc()) .select(table.fetch(PetFetcher.$.allScalarFields() .owner(OwnerFetcher.$.allScalarFields() .city(CityFetcher.$.allScalarFields())))) .limit(PAGE_SIZE) .execute();
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE p.id > ? ORDER BY p.id LIMIT 21 -- page size + 1 detects a next page; the literal count lets PostgreSQL cache the plan
SELECT … -- the same three-table join WHERE p.id > ? ORDER BY p.id LIMIT 20 -- inlined literal: PostgreSQL settles on a cached generic plan
SELECT … -- the same three-table join WHERE p.id > ? ORDER BY p.id FETCH FIRST 20 ROWS ONLY -- the HQL limit clause renders the constant: cached generic plan
SELECT … -- the same three-table join WHERE p.id > ? ORDER BY p.id FETCH NEXT 20 ROWS ONLY -- DSL.inline renders the constant: cached generic plan
SELECT … -- the same three-table join WHERE p.id > ? ORDER BY p.id LIMIT 20 -- inlined literal: PostgreSQL settles on a cached generic plan
-- 1) one page of pets SELECT pet.id, pet.name, pet.birth_date, pet.type_id, pet.owner_id FROM pet WHERE pet.id > ? ORDER BY pet.id LIMIT 20 -- 2) batched owners, 3) batched cities SELECT … FROM owner WHERE owner.id IN (?, …) SELECT … FROM city WHERE city.id IN (?, …)
SELECT … FROM pet LEFT JOIN owner _ref0 ON pet.owner_id = _ref0.id -- reference bindings join with LEFT JOIN LEFT JOIN city _ref1 ON _ref0.city_id = _ref1.id WHERE pet.id > ? ORDER BY pet.id LIMIT ? -- bound page size: replanned on every execution
SELECT … FROM pet WHERE id > ? ORDER BY id LIMIT ? -- then the fetcher loads the associations in batched queries: SELECT … FROM owner WHERE id = ANY(?) SELECT … FROM city WHERE id = ANY(?)
No fetch joins to spell out and no N+1 to dodge: the entity graph declares what a Pet is, so selecting pets hydrates owner and city from one query. When reading the three sizes, note that the bound range predicate races PostgreSQL's plan-cache decision, which follows the bind values: 10-row spans keep every implementation on per-call custom planning, while 100-row spans sit at the custom-versus-generic cost crossover and can settle either way per statement, which is why the 100-row column carries a plan-regime component on top of framework overhead (a baseline faster at 100 rows than at 10 is the cached-plan signature). The 1,000-row join, where the regimes converge, is the cleanest read of per-row mapping cost.
val result = pets.select() .where((Pet_.id greater base) and (Pet_.id lessEq base + rows)) .resultList // Pet, Owner and City hydrated from one query
try (var ps = connection.prepareStatement( "SELECT p.id, p.name, ..., c.id, c.name FROM pet p" + " JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id" + " WHERE p.id > ? AND p.id <= ?")) { ps.setLong(1, base); ps.setLong(2, base + rows); try (var rs = ps.executeQuery()) { List<Pet> pets = new ArrayList<>(); while (rs.next()) pets.add(mapPet(rs)); // hand-map each row -> Pet, Owner, City return pets; } }
return sessionFactory.fromSession(session -> session .createSelectionQuery( "from Pet p join fetch p.owner o join fetch o.city where p.id > :base and p.id <= :top", Pet.class) .setParameter("base", base) .setParameter("top", base + rows) .getResultList());
return ctx.select( PET.ID, PET.NAME, PET.BIRTH_DATE, PET.TYPE_ID, row(OWNER.ID, OWNER.FIRST_NAME, OWNER.LAST_NAME, OWNER.ADDRESS, OWNER.TELEPHONE, row(CITY.ID, CITY.NAME).mapping(City::new)).mapping(Owner::new)) .from(PET) .join(OWNER).on(PET.OWNER_ID.eq(OWNER.ID)) .join(CITY).on(OWNER.CITY_ID.eq(CITY.ID)) .where(PET.ID.gt(base).and(PET.ID.le(base + rows))) .fetch(Records.mapping(Pet::new));
transaction(database) { (Pets innerJoin Owners innerJoin Cities) .selectAll() .where { (Pets.id greater base) and (Pets.id lessEq base + rows) } .map { it.toPet() } }
transaction(database) { PetDao.wrapRows( Pets.selectAll() .where { (Pets.id greater base) and (Pets.id lessEq base + rows) }) .with(PetDao::owner, OwnerDao::city) // 2 extra batched SELECT ... IN queries .map { it.toPet() } }
database.sequenceOf(Pets) .filter { (Pets.id greater base) and (Pets.id lessEq base + rows) } .toList() // reference bindings join owner and city
PetTable table = PetTable.$; return sqlClient.createQuery(table) .where(table.id().gt(base)).where(table.id().le(base + rows)) .select(table.fetch( PetFetcher.$.allScalarFields() .owner(OwnerFetcher.$.allScalarFields() .city(CityFetcher.$.allScalarFields())))) // fetchers -> 2 batched loads .execute();
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE p.id > ? AND p.id <= ?
-- 1) main pet query SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id FROM pet p WHERE p.id > ? AND p.id <= ? -- 2) batched owners SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id IN (?, ?, ...) -- 3) batched cities SELECT c.id, c.name FROM city c WHERE c.id IN (?, ?, ...)
SELECT … -- all pet, owner and city columns FROM pet LEFT JOIN owner _ref0 ON pet.owner_id = _ref0.id -- reference bindings join with LEFT JOIN LEFT JOIN city _ref1 ON _ref0.city_id = _ref1.id WHERE (pet.id > ?) AND (pet.id <= ?)
-- 1) main pet query (owner_id kept as FK for the batch load) SELECT p.id, p.name, p.birth_date, p.owner_id FROM pet p WHERE p.id > ? AND p.id <= ? -- 2) batched owners SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id FROM owner o WHERE o.id = ANY(?) -- array bind, chunked for large id sets -- 3) batched cities SELECT c.id, c.name FROM city c WHERE c.id = ANY(?)
These benchmarks measure single-threaded operation latency on PostgreSQL. They do not measure application throughput, connection-pool contention, startup time, memory use, native-image performance or behaviour on other databases.
Every rule below is enforced by the harness code, not just described here.
SELECT 1 baseline measured about 84 µs, and every score includes it. That compresses relative differences; the mapping-heavy workloads are where library differences show.Versions: Storm 1.14.0, Hibernate 7.4.7, jOOQ 3.21.7, Exposed 1.5.0, Ktorm 4.2.1, Jimmer 0.11.7, PostgreSQL 17, JDK 21.
The published figures come from the repository's benchmark GitHub Actions workflow on a GitHub-hosted dedicated runner (dedicated, 4 vCPU, 16 GB, Ubuntu 24.04), which builds Storm from source at the commit stated with each run, executes the full suite against PostgreSQL 17 in Docker, and uploads the results as an artifact. The raw per-fork JMH data, the merged tables and a metadata file recording the exact versions, runner and JMH configuration are committed under results/ in the repository, so every published number can be recomputed from its artifacts.
To reproduce: fork the repository and dispatch the benchmark workflow, choosing the Storm ref to build, the runner label and the mode; a comparable dedicated runner class gives comparable stability. Or run the suite locally with JDK 21 and Docker: scripts/run.sh starts one tuned container and runs every module. Absolute numbers depend on the hardware; the comparison within a table is the point.