From 2c9dea5af4ddfd0c99198444cefaf238d3ef164d Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 16 Jul 2026 23:32:53 +0200 Subject: [PATCH 1/2] feat(website): refresh benchmarks with the final run and streamline the cards Update the benchmark page and homepage to the final 2026-07-16 re-run: all eight workloads, the ~155 us round trip, and the measured date. - Benchmarks: rewrite the three highlight cards (workloads won, worst-case gap, overhead vs the next-fastest ORM) and refresh the matrix summary for the new numbers (Exposed no longer edges Storm on the 1,000-row join). - Homepage: the vs cards always show a comparison (no deselect, feature fronts removed) and carry the updated per-ORM speed figures. - Clone-bar copy button flips to a green check on click. --- .../src/components/tutorial/tutorialTheme.js | 6 +- website/src/pages/benchmarks.js | 30 ++++----- website/src/pages/index.js | 65 ++++++------------- 3 files changed, 39 insertions(+), 62 deletions(-) diff --git a/website/src/components/tutorial/tutorialTheme.js b/website/src/components/tutorial/tutorialTheme.js index 0b8d19518..09956da5c 100644 --- a/website/src/components/tutorial/tutorialTheme.js +++ b/website/src/components/tutorial/tutorialTheme.js @@ -136,7 +136,7 @@ export function editor({file, tag, code, sql, copy, variants}) { export function clonebar(command) { const data = esc(command).replace(/"/g, '"'); return `
$${esc(command)}` + - `
`; + ``; } // A compact two-pane "at a glance" comparison for one-liner contrasts. Only @@ -361,6 +361,10 @@ export const TUT_CSS = ` .storm-tut .sqlbtn .ico,.storm-tut .copybtn .ico{width:13px;height:13px;opacity:.9} .storm-tut .clonebar .clonecmd{overflow-x:auto} .storm-tut .clonebar .copybtn{margin-left:auto;flex:none;gap:0;padding:5px 8px} + .storm-tut .clonebar .copybtn .ico-check{display:none} + .storm-tut .clonebar .copybtn.on{color:#7ee787;border-color:rgba(126,231,135,.5);background:rgba(126,231,135,.12)} + .storm-tut .clonebar .copybtn.on .ico-copy{display:none} + .storm-tut .clonebar .copybtn.on .ico-check{display:inline-block} /* variant selector (same chrome as the buttons; native select behind a custom chevron because appearance:none drops the platform arrow) */ .storm-tut .varwrap{position:relative;margin-left:14px;display:inline-flex;align-items:center} diff --git a/website/src/pages/benchmarks.js b/website/src/pages/benchmarks.js index 608ea0755..2d1362561 100644 --- a/website/src/pages/benchmarks.js +++ b/website/src/pages/benchmarks.js @@ -10,7 +10,7 @@ const DESC = 'Reproducible JMH benchmarks of Storm against JDBC, Hibernate, jOOQ // Results from the reproducible suite: one tuned PostgreSQL 17 container over TCP, JMH, 2 forks, // 5x3s measured iterations, single thread. Values are mean us/op with the reported error. -// Rows are same-session comparisons; the raw JDBC single round trip measured ~158 us. +// Rows are same-session comparisons; the raw JDBC single round trip measured ~155 us. const LIBS = { jdbc: {name: 'JDBC', cls: 'jdbc'}, storm: {name: 'Storm', cls: 'storm'}, @@ -26,49 +26,49 @@ const WORKLOADS = [ id: 'singleRowById', title: 'Primary key lookup', desc: 'Load one visit by primary key. The purest round-trip test: one query, one row.', - results: {jdbc: [171.9, 5.7], hibernate: [180.7, 5.1], storm: [182.3, 7.1], jooq: [184.2, 3.8], jimmer: [184.4, 7.9], exposed: [369.5, 9.1], exposedDao: [377.3, 5.8]}, + results: {jdbc: [172.4, 3.3], hibernate: [180.8, 3.2], storm: [182.7, 7.7], jimmer: [182.7, 6.9], jooq: [184.7, 8.2], exposed: [369.4, 9.5], exposedDao: [374.8, 17.4]}, }, { id: 'joinWithMapping10', title: 'Three-table join · 10 rows', desc: 'Load pets with owner and city hydrated through a single three-table join.', - results: {jdbc: [388.4, 6.8], storm: [443.7, 9.4], hibernate: [447.6, 8.4], jooq: [476.4, 11.4], exposed: [579.1, 5.5], jimmer: [605.7, 20.5], exposedDao: [797.5, 21.9]}, + results: {jdbc: [395.6, 15.2], storm: [464.1, 80.9], hibernate: [472.3, 28.2], jooq: [490.4, 20.5], exposed: [581.2, 14.6], jimmer: [600.0, 19.7], exposedDao: [798.1, 26.8]}, }, { id: 'joinWithMapping100', title: 'Three-table join · 100 rows', desc: 'The same join at 100 rows. Hydration cost starts to separate the field.', - results: {jdbc: [545.4, 9.9], storm: [803.7, 31.7], exposed: [814.4, 7.8], jimmer: [981.3, 15.0], jooq: [1043.2, 110.8], hibernate: [1172.4, 241.6], exposedDao: [1311.8, 343.9]}, + results: {jdbc: [569.2, 12.9], storm: [792.9, 16.6], exposed: [813.5, 15.4], jimmer: [1005.0, 152.3], jooq: [1315.3, 161.5], hibernate: [1489.9, 246.6], exposedDao: [1995.6, 290.6]}, }, { id: 'joinWithMapping1000', title: 'Three-table join · 1,000 rows', desc: 'The same join at 1,000 rows. Row mapping now dominates the round trip.', - results: {jdbc: [1440.9, 102.0], exposed: [2792.4, 215.9], storm: [3163.7, 156.8], hibernate: [3665.2, 132.7], jooq: [3791.3, 76.3], exposedDao: [8220.8, 783.0], jimmer: [8446.6, 3990.8]}, + results: {jdbc: [1737.3, 317.0], storm: [2242.0, 288.8], exposed: [2730.4, 299.8], hibernate: [3463.7, 378.8], jooq: [3536.6, 261.3], exposedDao: [7575.4, 2485.6], jimmer: [10227.4, 4670.0]}, }, { id: 'projection', title: 'Projection', desc: 'Three columns across three tables into a flat DTO, 100 rows.', - results: {jdbc: [729.0, 16.9], hibernate: [757.1, 25.6], jimmer: [758.3, 14.7], storm: [786.4, 18.1], jooq: [788.4, 17.5], exposed: [1011.3, 16.8], exposedDao: [1032.2, 18.4]}, + results: {jdbc: [778.3, 24.4], storm: [787.5, 29.4], hibernate: [795.1, 23.4], jimmer: [823.7, 31.1], jooq: [825.8, 27.4], exposed: [1039.5, 31.4], exposedDao: [1042.7, 29.4]}, }, { id: 'batchInsert', title: 'Batch insert', desc: 'Insert 100 visits atomically and fetch the generated keys.', - results: {jdbc: [2404.2, 32.4], jooq: [2436.0, 287.3], storm: [2720.7, 131.3], exposed: [3165.3, 260.8], hibernate: [3535.7, 194.5], jimmer: [3739.3, 170.3], exposedDao: [3986.1, 451.4]}, + results: {jdbc: [2546.7, 96.9], jooq: [2842.1, 449.2], storm: [3000.1, 238.6], exposed: [3499.0, 294.2], hibernate: [3911.8, 396.1], jimmer: [4049.7, 226.7], exposedDao: [4301.3, 429.5]}, }, { id: 'updateById', title: 'Read, modify, update', desc: 'Read one owner, change one field, persist atomically. Every implementation reads a lazy association shape and writes only the changed column.', - results: {jdbc: [538.4, 4.8], exposed: [553.0, 18.8], hibernate: [557.2, 8.6], exposedDao: [564.0, 16.9], storm: [571.9, 12.6], jooq: [726.2, 21.2], jimmer: [901.0, 21.9]}, + results: {jdbc: [531.6, 19.2], hibernate: [557.2, 13.3], exposed: [557.6, 19.4], storm: [564.4, 13.5], exposedDao: [568.6, 16.3], jooq: [727.9, 24.9], jimmer: [888.6, 12.7]}, }, { id: 'objectGraph', title: 'Object graph', desc: 'Load the owners of a city, each with their list of pets. The one-to-many shape every application has.', - results: {jdbc: [854.6, 42.5], jooq: [971.9, 106.6], storm: [1191.2, 59.1], exposed: [1328.0, 25.9], hibernate: [1329.4, 162.3], exposedDao: [1380.3, 120.2], jimmer: [1406.9, 25.5]}, + results: {jdbc: [973.5, 29.9], storm: [1191.3, 182.7], exposed: [1378.0, 43.6], jooq: [1386.7, 197.3], jimmer: [1401.2, 68.9], exposedDao: [1609.3, 151.0], hibernate: [1677.7, 356.4]}, }, ]; @@ -418,18 +418,18 @@ ${navHtml('benchmarks')}

Concise by design.
Fast by measurement.

Storm set out to be the most enjoyable ORM to work with, entities as plain records, queries that read like the SQL they produce. This page shows that the same design keeps the hot path lean, measured against six alternatives on identical workloads, with the code behind every number.

-

PostgreSQL 17 over TCP · JMH · Storm 1.13.0 · measured 2026-07-14

+

PostgreSQL 17 over TCP · JMH · Storm 1.13.0 · measured 2026-07-16

-
+10 µsis all Storm adds to a 172 µs primary key lookup over raw JDBC. The abstraction is nearly free.
-
26% lessoverhead over raw JDBC than the closest alternative, averaged across all eight workloads.
-
72% lessentity code than JPA: the five-table model is 29 lines in Storm, 105 as JPA entities.
+
5 of 8workloads are won by Storm, more than any other ORM.
+
5.6%is the most Storm trails the fastest ORM on any workload. It remains close to the lead throughout.
+
62% loweroverhead than jOOQ (the next-fastest ORM) relative to raw JDBC across all workloads.

At a glance

Seven implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. Mean latency per operation, lower is better. Cells are tinted by distance from the fastest framework in the row, green through red. Percentages are overhead over raw JDBC. Raw JDBC is the reference floor.

${matrixHtml()} -

Each library excels in certain areas. Storm has the lowest average measured overhead over raw JDBC across these eight workloads, while individual workloads favor different libraries: raw JDBC is fastest everywhere by construction, and Exposed's DSL edges Storm on the 1,000-row three-table join and the read-modify-update workload. Across the suite Storm is consistently the fastest full framework or within a third of it. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.

+

Every library has strong rows, but Storm's is the only column that never runs hot. On every workload Storm is either the fastest framework or close behind it, while every alternative has at least one workload where it costs a third more than the best, and most cost far more than that. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.

Per-workload charts: the same numbers with their reported error @@ -477,7 +477,7 @@ ${charts}

Method and fairness

The suite is built to be argued with. Everything below is enforced in code, not prose.

    -
  • Real round trips. One tuned PostgreSQL 17 container, reached over TCP. The raw JDBC single round trip measured about 158 µs, and every score includes it. That compresses relative differences; the mapping-heavy workloads are where library differences show.
  • +
  • Real round trips. One tuned PostgreSQL 17 container, reached over TCP. The raw JDBC single round trip measured about 155 µs, and every score includes it. That compresses relative differences; the mapping-heavy workloads are where library differences show.
  • JMH, properly. Two forks, five 3-second measurement iterations after warmup, single thread: latency, not throughput. Sanity checks run every workload once per trial and verify row counts before anything is timed.
  • Same work for everyone. Identical schema and data, identical transaction boundaries on writes, and update values derived from the value just read, so change-detecting libraries can never silently skip a write. On the update workload every implementation writes only the changed column and reads a lazy association shape.
  • Idiomatic code for everyone. Each library is written the way its documentation recommends: Hibernate with join fetch and @DynamicUpdate, jOOQ with generated records and MULTISET, Jimmer with fetchers, Exposed in both DSL and DAO flavors.
  • diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 718f67c33..359d70022 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -60,18 +60,12 @@ const CSS = ` .storm-home .card.bcard{padding:18px 22px} .storm-home .bcard.bench{min-height:186px} .storm-home .bcard{position:relative;overflow:hidden} - .storm-home .bcard .bfront p{font-size:12.5px;line-height:1.5} - .storm-home .bcard .bfront .ic{width:26px;height:26px;margin-bottom:8px} - .storm-home .bcard .bfront h3{font-size:15px;margin-bottom:6px} .storm-home .bcard .bback p{font-size:12.5px;line-height:1.5} .storm-home .bcard .bback h3{font-size:15.5px;margin-bottom:7px} .storm-home .bface{transition:opacity .55s ease,transform .55s ease} .storm-home .bcard:nth-child(2) .bface{transition-delay:.12s} .storm-home .bcard:nth-child(3) .bface{transition-delay:.24s} - .storm-home .bfront{position:relative} - .storm-home .bback{position:absolute;inset:0;padding:inherit;opacity:0;transform:translateY(10px);pointer-events:none} - .storm-home .bcard.bench .bfront{position:absolute;inset:0;padding:inherit;opacity:0;transform:translateY(-10px);pointer-events:none} - .storm-home .bcard.bench .bback{position:relative;inset:auto;padding:0;opacity:1;transform:none;pointer-events:auto} + .storm-home .bback{position:relative;inset:auto;padding:0;opacity:1;transform:none;pointer-events:auto} .storm-home .bnum{font-size:42px;font-weight:800;line-height:1;margin-bottom:8px;letter-spacing:-.02em;background:linear-gradient(100deg,#feeeb0,#fbbf24 55%,#f59e0b);-webkit-background-clip:text;background-clip:text;color:transparent} .storm-home .bback a{color:var(--accent);text-decoration:none;font-weight:600;white-space:nowrap} .storm-home .bback a:hover{text-decoration:underline} @@ -308,40 +302,25 @@ const BODY = `
    Benchmark againstSee the benchmarks →
    -
    -
    -
    -

    Direct database control

    -

    Every query is explicit and predictable. Nested predicates and entity graphs compile to a single efficient query, eliminating accidental hidden N+1 queries.

    -
    +
    -
    -

    -

    +
    17%
    +

    faster than Hibernate

    +

    Averaged across 8 workloads.

    -
    -
    -
    -

    Stateless and Immutable

    -

    Immutable data classes. No proxies, no flush, no hidden state. What you see is what you get. Safe to cache, share, and serialize across every layer.

    -
    +
    -
    -

    -

    +
    72%
    +

    fewer entity lines

    +

    The five-table model: 29 lines in Storm, 105 in Hibernate.

    -
    -
    -
    -

    Type-safe and Injection-safe

    -

    Compile-time detection of column and type errors. Automatic conversion of interpolated values into bind parameters.

    -
    +
    -
    -

    -

    +
    19%
    +

    fewer query lines

    +

    All eight workloads: 100 lines in Storm, 123 in Hibernate, with no query strings.

    @@ -382,15 +361,15 @@ const BODY = ` export default function Home() { useEffect(() => { - // Pick your ORM: the feature cards flip to Storm-vs-X measured figures (run of 2026-07-14). + // Pick your ORM: the feature cards show Storm-vs-X measured figures (run of 2026-07-16). const VS = { hibernate: { - speed: ['11%', 'faster than Hibernate', 'Averaged across 8 workloads.'], + speed: ['17%', 'faster than Hibernate', 'Averaged across 8 workloads.'], entities: ['72%', 'fewer entity lines', 'The five-table model: 29 lines in Storm, 105 in Hibernate.'], queries: ['19%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 123 in Hibernate, with no query strings.'], }, jooq: { - speed: ['7%', 'faster than jOOQ', 'Averaged across 8 workloads.'], + speed: ['15%', 'faster than jOOQ', 'Averaged across 8 workloads.'], entities: ['29 lines', 'instead of manual mapping', 'jOOQ maps results by hand into DTOs; Storm turns one 29-line model into typed rows everywhere.'], queries: ['24%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 131 in jOOQ, no hand-written row mapping.'], }, @@ -400,12 +379,12 @@ export default function Home() { queries: ['22%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 128 in Exposed, no hand-written row mapping.'], }, exposedDao: { - speed: ['39%', 'faster than Exposed DAO', 'Averaged across 8 workloads.'], + speed: ['38%', 'faster than Exposed DAO', 'Averaged across 8 workloads.'], entities: ['58%', 'fewer entity lines', 'One data class per table in Storm; Exposed DAO needs the table object, the DAO class and a DTO.'], queries: ['19%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 124 in Exposed DAO.'], }, jimmer: { - speed: ['30%', 'faster than Jimmer', 'Averaged across 8 workloads.'], + speed: ['25%', 'faster than Jimmer', 'Averaged across 8 workloads.'], entities: ['47%', 'fewer entity lines', 'The five-table model: 29 lines of data classes in Storm, 55 lines of interfaces in Jimmer.'], queries: ['31%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 144 in Jimmer.'], }, @@ -417,14 +396,8 @@ export default function Home() { }; const vsCards = [...document.querySelectorAll('.storm-home .bcard')]; const vsChips = [...document.querySelectorAll('.storm-home .vs-chips button')]; - let vsActive = null; function applyVs(key) { - vsActive = key; vsChips.forEach((chip) => chip.classList.toggle('on', chip.dataset.vs === key)); - if (!key) { - vsCards.forEach((card) => card.classList.remove('bench')); - return; - } const data = VS[key]; vsCards.forEach((card) => { const back = card.querySelector('.bback'); @@ -446,7 +419,7 @@ export default function Home() { }, 7000); vsChips.forEach((chip) => chip.addEventListener('click', () => { clearInterval(vsAuto); - applyVs(vsActive === chip.dataset.vs ? null : chip.dataset.vs); + applyVs(chip.dataset.vs); })); applyVs('hibernate'); From 08d699502e0232faa184a36fc9e8e8c22637c743 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 17 Jul 2026 01:06:56 +0200 Subject: [PATCH 2/2] feat(website): show every library's code and SQL per benchmark workload Extend the benchmark page so each workload and the model expose all seven implementations behind the variant selector, not just Storm. - editor() gains per-variant sql, tag and filename, so Show SQL and the tab chrome switch with the selected library (backward compatible). - Each workload carries the real code from the benchmark suite, and the Show SQL panel shows the exact statement each library issues: jOOQ's multi-row insert and MULTISET, Hibernate's sequence pre-allocation and DISTINCT join fetch, Exposed DAO and Jimmer's batched secondary queries. - The model shows City, Owner and Pet in full for every library. - Copy: reworked matrix summary and stat cards; "On average across 8 workloads" on the homepage. --- .../src/components/tutorial/tutorialTheme.js | 31 +- website/src/pages/benchmarks.js | 628 +++++++++++++++++- website/src/pages/index.js | 10 +- 3 files changed, 632 insertions(+), 37 deletions(-) diff --git a/website/src/components/tutorial/tutorialTheme.js b/website/src/components/tutorial/tutorialTheme.js index 09956da5c..a65a132f9 100644 --- a/website/src/components/tutorial/tutorialTheme.js +++ b/website/src/components/tutorial/tutorialTheme.js @@ -96,7 +96,12 @@ export function editor({file, tag, code, sql, copy, variants}) { .map((v, i) => ``) .join('')}` : ''; - const sqlBtn = sql + // Per-variant SQL: a variant may carry its own `sql` (e.g. jOOQ's MULTISET or + // multi-row insert), otherwise it falls back to the shared `sql`. The Show SQL + // panel then switches with the selected variant. + const hasVariantSql = variants && variants.some((v) => v.sql); + const hasSql = Boolean(sql) || hasVariantSql; + const sqlBtn = hasSql ? `Show SQL` : ''; const copyText = @@ -104,8 +109,12 @@ export function editor({file, tag, code, sql, copy, variants}) { const copyBtn = copyText ? `Copy` : ''; - const sqlConsole = sql - ? `
    generated sql
    ${sql}
    ` + const sqlConsole = hasSql + ? `
    generated sql
    ${ + hasVariantSql + ? variants.map((v, i) => `
    ${v.sql || sql || ''}
    `).join('') + : `
    ${sql}
    ` + }
    ` : ''; const codeareas = variants ? variants @@ -113,7 +122,7 @@ export function editor({file, tag, code, sql, copy, variants}) { const paneCopy = v.copy || (copy === true ? toPlain(v.code) : null); return `
    ${pane(v.code)}
    `; + }${v.tag ? ` data-tag="${esc(v.tag)}"` : ''}${v.file ? ` data-file="${esc(v.file)}"` : ''}>${pane(v.code)}
    `; }) .join('') : `
    ${pane(code)}
    `; @@ -121,8 +130,8 @@ export function editor({file, tag, code, sql, copy, variants}) {
    - ${esc(file)} - ${tag ? `${esc(tag)}` : ''} + ${esc((variants && variants[selectedIdx] && variants[selectedIdx].file) || file || '')} + ${esc((variants && variants[selectedIdx] && variants[selectedIdx].tag) || tag || '')} ${varSel}${sqlBtn}${copyBtn}
    ${codeareas} @@ -205,12 +214,20 @@ export function wireSqlToggles() { const sel = ed.querySelector('.varsel'); if (sel) { const panes = ed.querySelectorAll('.varpane'); + const sqlPanes = ed.querySelectorAll('.sqlpane'); const onChange = () => { const idx = Number(sel.value); panes.forEach((p, i) => p.classList.toggle('on', i === idx)); + sqlPanes.forEach((p, i) => p.classList.toggle('on', i === idx)); // A variant can carry its own copy text (data-copy on the pane). const cp = ed.querySelector('.copybtn'); if (cp && panes[idx] && panes[idx].dataset.copy) cp.dataset.copy = panes[idx].dataset.copy; + // A variant can carry its own language tag (data-tag on the pane). + const lt = ed.querySelector('.langtag'); + if (lt && panes[idx] && panes[idx].dataset.tag) lt.textContent = panes[idx].dataset.tag; + // A variant can carry its own filename (data-file on the pane). + const fn = ed.querySelector('.fname'); + if (fn && panes[idx] && panes[idx].dataset.file) fn.textContent = panes[idx].dataset.file; }; sel.addEventListener('change', onChange); cleanups.push(() => sel.removeEventListener('change', onChange)); @@ -391,6 +408,8 @@ export const TUT_CSS = ` .storm-tut .sqlconsole{display:none;border-top:1px solid var(--border-soft);background:var(--statusbg)} .storm-tut .editor.show-sql .sqlconsole,.storm-tut .editor.show-sql .sqlhead{display:flex} .storm-tut .sqlpanel{margin:0;padding:14px 18px 18px;font-family:var(--mono);font-size:12.5px;line-height:1.75;white-space:pre;overflow-x:auto;color:var(--plain);flex:1} + .storm-tut .sqlconsole .sqlpane{display:none} + .storm-tut .sqlconsole .sqlpane.on{display:block} .storm-tut .sqlk{color:var(--accent)}.storm-tut .sqlq{color:var(--num)}.storm-tut .sqlc{color:var(--com)} /* at-a-glance two-pane strip */ diff --git a/website/src/pages/benchmarks.js b/website/src/pages/benchmarks.js index 2d1362561..359847868 100644 --- a/website/src/pages/benchmarks.js +++ b/website/src/pages/benchmarks.js @@ -2,7 +2,7 @@ import React, {useEffect} from 'react'; import Head from '@docusaurus/Head'; import { TUT_CSS, navHtml, FOOT_HTML, wireSqlToggles, editor, clonebar, - K, T, S, C, F, N, A, P, QK, QQ, + K, T, S, C, F, N, A, P, QK, QQ, QC, } from '../components/tutorial/tutorialTheme'; const TITLE = 'Benchmarks · ST/ORM vs Hibernate, jOOQ, Exposed and Jimmer'; @@ -169,6 +169,149 @@ const CODE_ENTITIES = [ ` ${A('@FK')} ${K('val')} owner: ${T('Owner')},`, `) : ${T('Entity')}<${T('Long')}>`, ].join('\n'); +const CODE_MODEL_JDBC = [ + `${C('// No ORM model. Rows are hand-mapped into plain records:')}`, + `${K('record')} ${T('City')}(${K('long')} id, ${T('String')} name) {}`, + ``, + `${K('record')} ${T('Owner')}(${K('long')} id, ${T('String')} firstName, ${T('String')} lastName,`, + ` ${T('String')} address, ${T('String')} telephone, ${T('City')} city) {}`, + ``, + `${K('record')} ${T('Pet')}(${K('long')} id, ${T('String')} name, ${T('LocalDate')} birthDate,`, + ` ${K('long')} typeId, ${T('Owner')} owner) {}`, +].join('\n'); +const CODE_MODEL_HIBERNATE = [ + `${C('// Private fields, field access. Getters/setters added only where the app calls them.')}`, + `${A('@Entity')} ${A('@Table')}(name = ${S('"city"')})`, + `${K('class')} ${T('City')} {`, + ` ${A('@Id')} ${A('@GeneratedValue')}(strategy = IDENTITY) ${T('Long')} id;`, + ` ${A('@Column')}(name = ${S('"name"')}) ${T('String')} name;`, + `}`, + ``, + `${A('@Entity')} ${A('@Table')}(name = ${S('"owner"')}) ${A('@DynamicUpdate')}`, + `${K('class')} ${T('Owner')} {`, + ` ${A('@Id')} ${A('@GeneratedValue')}(strategy = IDENTITY) ${T('Long')} id;`, + ` ${A('@Column')}(name = ${S('"first_name"')}) ${T('String')} firstName;`, + ` ${A('@Column')}(name = ${S('"last_name"')}) ${T('String')} lastName;`, + ` ${A('@Column')}(name = ${S('"address"')}) ${T('String')} address;`, + ` ${A('@Column')}(name = ${S('"telephone"')}) ${T('String')} telephone;`, + ` ${A('@ManyToOne')}(fetch = LAZY) ${A('@JoinColumn')}(name = ${S('"city_id"')}) ${T('City')} city;`, + ` ${A('@OneToMany')}(mappedBy = ${S('"owner"')}) ${T('List')}<${T('Pet')}> pets;`, + `}`, + ``, + `${A('@Entity')} ${A('@Table')}(name = ${S('"pet"')})`, + `${K('class')} ${T('Pet')} {`, + ` ${A('@Id')} ${A('@GeneratedValue')}(strategy = IDENTITY) ${T('Long')} id;`, + ` ${A('@Column')}(name = ${S('"name"')}) ${T('String')} name;`, + ` ${A('@Column')}(name = ${S('"birth_date"')}) ${T('LocalDate')} birthDate;`, + ` ${A('@ManyToOne')}(fetch = LAZY) ${A('@JoinColumn')}(name = ${S('"type_id"')}) ${T('PetType')} type;`, + ` ${A('@ManyToOne')}(fetch = LAZY) ${A('@JoinColumn')}(name = ${S('"owner_id"')}) ${T('Owner')} owner;`, + `}`, +].join('\n'); +const CODE_MODEL_JOOQ = [ + `${C('// jOOQ generates the table classes from the schema; there is no entity model.')}`, + `${C('// You still write the result records it maps into, shared here with JDBC:')}`, + `${K('record')} ${T('City')}(${K('long')} id, ${T('String')} name) {}`, + ``, + `${K('record')} ${T('Owner')}(${K('long')} id, ${T('String')} firstName, ${T('String')} lastName,`, + ` ${T('String')} address, ${T('String')} telephone, ${T('City')} city) {}`, + ``, + `${K('record')} ${T('Pet')}(${K('long')} id, ${T('String')} name, ${T('LocalDate')} birthDate,`, + ` ${K('long')} typeId, ${T('Owner')} owner) {}`, +].join('\n'); +const CODE_MODEL_EXPOSED = [ + `${K('object')} ${T('Cities')} : ${T('Table')}(${S('"city"')}) {`, + ` ${K('val')} id = ${F('long')}(${S('"id"')}).${F('autoIncrement')}()`, + ` ${K('val')} name = ${F('varchar')}(${S('"name"')}, ${N('100')})`, + ` ${K('override val')} primaryKey = ${T('PrimaryKey')}(id)`, + `}`, + ``, + `${K('object')} ${T('Owners')} : ${T('Table')}(${S('"owner"')}) {`, + ` ${K('val')} id = ${F('long')}(${S('"id"')}).${F('autoIncrement')}()`, + ` ${K('val')} firstName = ${F('varchar')}(${S('"first_name"')}, ${N('50')})`, + ` ${K('val')} lastName = ${F('varchar')}(${S('"last_name"')}, ${N('50')})`, + ` ${K('val')} address = ${F('varchar')}(${S('"address"')}, ${N('120')})`, + ` ${K('val')} telephone = ${F('varchar')}(${S('"telephone"')}, ${N('20')})`, + ` ${K('val')} cityId = ${F('long')}(${S('"city_id"')}).${F('references')}(${T('Cities')}.id)`, + ` ${K('override val')} primaryKey = ${T('PrimaryKey')}(id)`, + `}`, + ``, + `${K('object')} ${T('Pets')} : ${T('Table')}(${S('"pet"')}) {`, + ` ${K('val')} id = ${F('long')}(${S('"id"')}).${F('autoIncrement')}()`, + ` ${K('val')} name = ${F('varchar')}(${S('"name"')}, ${N('50')})`, + ` ${K('val')} birthDate = ${F('date')}(${S('"birth_date"')})`, + ` ${K('val')} typeId = ${F('long')}(${S('"type_id"')}).${F('references')}(${T('PetTypes')}.id)`, + ` ${K('val')} ownerId = ${F('long')}(${S('"owner_id"')}).${F('references')}(${T('Owners')}.id)`, + ` ${K('override val')} primaryKey = ${T('PrimaryKey')}(id)`, + `}`, +].join('\n'); +const CODE_MODEL_EXPOSED_DAO = [ + `${K('object')} ${T('Cities')} : ${T('LongIdTable')}(${S('"city"')}) { ${K('val')} name = ${F('varchar')}(${S('"name"')}, ${N('100')}) }`, + ``, + `${K('object')} ${T('Owners')} : ${T('LongIdTable')}(${S('"owner"')}) {`, + ` ${K('val')} firstName = ${F('varchar')}(${S('"first_name"')}, ${N('50')})`, + ` ${K('val')} lastName = ${F('varchar')}(${S('"last_name"')}, ${N('50')})`, + ` ${K('val')} address = ${F('varchar')}(${S('"address"')}, ${N('120')})`, + ` ${K('val')} telephone = ${F('varchar')}(${S('"telephone"')}, ${N('20')})`, + ` ${K('val')} cityId = ${F('reference')}(${S('"city_id"')}, ${T('Cities')})`, + `}`, + ``, + `${K('object')} ${T('Pets')} : ${T('LongIdTable')}(${S('"pet"')}) {`, + ` ${K('val')} name = ${F('varchar')}(${S('"name"')}, ${N('50')})`, + ` ${K('val')} birthDate = ${F('date')}(${S('"birth_date"')})`, + ` ${K('val')} typeId = ${F('reference')}(${S('"type_id"')}, ${T('PetTypes')})`, + ` ${K('val')} ownerId = ${F('reference')}(${S('"owner_id"')}, ${T('Owners')})`, + `}`, + ``, + `${K('class')} ${T('CityDao')}(id: ${T('EntityID')}<${T('Long')}>) : ${T('LongEntity')}(id) {`, + ` ${K('companion object')} : ${T('LongEntityClass')}<${T('CityDao')}>(${T('Cities')})`, + ` ${K('var')} name ${K('by')} ${T('Cities')}.name`, + `}`, + ``, + `${K('class')} ${T('OwnerDao')}(id: ${T('EntityID')}<${T('Long')}>) : ${T('LongEntity')}(id) {`, + ` ${K('companion object')} : ${T('LongEntityClass')}<${T('OwnerDao')}>(${T('Owners')})`, + ` ${K('var')} firstName ${K('by')} ${T('Owners')}.firstName`, + ` ${K('var')} lastName ${K('by')} ${T('Owners')}.lastName`, + ` ${K('var')} address ${K('by')} ${T('Owners')}.address`, + ` ${K('var')} telephone ${K('by')} ${T('Owners')}.telephone`, + ` ${K('var')} city ${K('by')} ${T('CityDao')} ${F('referencedOn')} ${T('Owners')}.cityId`, + ` ${K('val')} pets ${K('by')} ${T('PetDao')} ${F('referrersOn')} ${T('Pets')}.ownerId`, + `}`, + ``, + `${K('class')} ${T('PetDao')}(id: ${T('EntityID')}<${T('Long')}>) : ${T('LongEntity')}(id) {`, + ` ${K('companion object')} : ${T('LongEntityClass')}<${T('PetDao')}>(${T('Pets')})`, + ` ${K('var')} name ${K('by')} ${T('Pets')}.name`, + ` ${K('var')} birthDate ${K('by')} ${T('Pets')}.birthDate`, + ` ${K('var')} typeId ${K('by')} ${T('Pets')}.typeId`, + ` ${K('var')} owner ${K('by')} ${T('OwnerDao')} ${F('referencedOn')} ${T('Pets')}.ownerId`, + `}`, +].join('\n'); +const CODE_MODEL_JIMMER = [ + `${A('@Entity')} ${A('@Table')}(name = ${S('"city"')})`, + `${K('interface')} ${T('City')} {`, + ` ${A('@Id')} ${K('long')} ${F('id')}();`, + ` ${T('String')} ${F('name')}();`, + `}`, + ``, + `${A('@Entity')} ${A('@Table')}(name = ${S('"owner"')})`, + `${K('interface')} ${T('Owner')} {`, + ` ${A('@Id')} ${K('long')} ${F('id')}();`, + ` ${T('String')} ${F('firstName')}();`, + ` ${T('String')} ${F('lastName')}();`, + ` ${T('String')} ${F('address')}();`, + ` ${T('String')} ${F('telephone')}();`, + ` ${A('@ManyToOne')} ${A('@JoinColumn')}(name = ${S('"city_id"')}) ${T('City')} ${F('city')}();`, + ` ${A('@OneToMany')}(mappedBy = ${S('"owner"')}) ${T('List')}<${T('Pet')}> ${F('pets')}();`, + `}`, + ``, + `${A('@Entity')} ${A('@Table')}(name = ${S('"pet"')})`, + `${K('interface')} ${T('Pet')} {`, + ` ${A('@Id')} ${K('long')} ${F('id')}();`, + ` ${T('String')} ${F('name')}();`, + ` ${T('LocalDate')} ${F('birthDate')}();`, + ` ${A('@ManyToOne')} ${A('@JoinColumn')}(name = ${S('"type_id"')}) ${T('PetType')} ${F('type')}();`, + ` ${A('@ManyToOne')} ${A('@JoinColumn')}(name = ${S('"owner_id"')}) ${T('Owner')} ${F('owner')}();`, + `}`, +].join('\n'); const CODE_SINGLE = [ `${K('val')} visit = visits.${F('getById')}(id)`, @@ -178,6 +321,39 @@ const SQL_SINGLE = [ `${QK('FROM')} visit v`, `${QK('WHERE')} v.id = ${QQ('?')}`, ].join('\n'); +const CODE_SINGLE_JDBC = [ + `${K('try')} (${K('var')} ps = connection.${F('prepareStatement')}(`, + ` ${S('"SELECT id, pet_id, visit_date, description FROM visit WHERE id = ?"')})) {`, + ` ps.${F('setLong')}(${N('1')}, id);`, + ` ${K('try')} (${K('var')} rs = ps.${F('executeQuery')}()) {`, + ` rs.${F('next')}();`, + ` ${K('return')} ${K('new')} ${T('Visit')}(rs.${F('getLong')}(${N('1')}), rs.${F('getLong')}(${N('2')}),`, + ` rs.${F('getObject')}(${N('3')}, ${T('LocalDate')}.${K('class')}), rs.${F('getString')}(${N('4')}));`, + ` }`, + `}`, +].join('\n'); +const CODE_SINGLE_HIBERNATE = [ + `${K('return')} sessionFactory.${F('fromSession')}(session -> session.${F('find')}(${T('Visit')}.${K('class')}, id));`, +].join('\n'); +const CODE_SINGLE_JOOQ = [ + `${K('return')} ctx.${F('select')}(${T('VISIT')}.ID, ${T('VISIT')}.PET_ID, ${T('VISIT')}.VISIT_DATE, ${T('VISIT')}.DESCRIPTION)`, + ` .${F('from')}(${T('VISIT')})`, + ` .${F('where')}(${T('VISIT')}.ID.${F('eq')}(id))`, + ` .${F('fetchOne')}(${T('Records')}.${F('mapping')}(${T('Visit')}::new));`, +].join('\n'); +const CODE_SINGLE_EXPOSED = [ + `${F('transaction')}(database) {`, + ` ${T('Visits')}.${F('selectAll')}().${F('where')} { ${T('Visits')}.id ${F('eq')} id }.${F('single')}().${F('toVisit')}()`, + `}`, +].join('\n'); +const CODE_SINGLE_EXPOSED_DAO = [ + `${F('transaction')}(database) {`, + ` ${T('VisitDao')}.${F('findById')}(id)!!.${F('toVisit')}()`, + `}`, +].join('\n'); +const CODE_SINGLE_JIMMER = [ + `${K('return')} sqlClient.${F('getEntities')}().${F('findById')}(${T('Visit')}.${K('class')}, id);`, +].join('\n'); const CODE_JOIN = [ `${K('val')} result = pets.${F('select')}()`, @@ -192,6 +368,90 @@ const SQL_JOIN = [ `${QK('INNER JOIN')} city c ${QK('ON')} o.city_id = c.id`, `${QK('WHERE')} p.id > ${QQ('?')} ${QK('AND')} p.id <= ${QQ('?')}`, ].join('\n'); +const CODE_JOIN_JDBC = [ + `${K('try')} (${K('var')} ps = connection.${F('prepareStatement')}(`, + ` ${S('"SELECT p.id, p.name, ..., c.id, c.name FROM pet p"')}`, + ` + ${S('" JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id"')}`, + ` + ${S('" WHERE p.id > ? AND p.id <= ?"')})) {`, + ` ps.${F('setLong')}(${N('1')}, base); ps.${F('setLong')}(${N('2')}, base + rows);`, + ` ${K('try')} (${K('var')} rs = ps.${F('executeQuery')}()) {`, + ` ${T('List')}<${T('Pet')}> pets = ${K('new')} ${T('ArrayList')}<>();`, + ` ${K('while')} (rs.${F('next')}()) pets.${F('add')}(${F('mapPet')}(rs)); ${C('// hand-map each row -> Pet, Owner, City')}`, + ` ${K('return')} pets;`, + ` }`, + `}`, +].join('\n'); +const CODE_JOIN_HIBERNATE = [ + `${K('return')} sessionFactory.${F('fromSession')}(session -> session`, + ` .${F('createSelectionQuery')}(`, + ` ${S('"from Pet p join fetch p.owner o join fetch o.city where p.id > :base and p.id <= :top"')},`, + ` ${T('Pet')}.${K('class')})`, + ` .${F('setParameter')}(${S('"base"')}, base)`, + ` .${F('setParameter')}(${S('"top"')}, base + rows)`, + ` .${F('getResultList')}());`, +].join('\n'); +const CODE_JOIN_JOOQ = [ + `${K('return')} ctx.${F('select')}(`, + ` ${T('PET')}.ID, ${T('PET')}.NAME, ${T('PET')}.BIRTH_DATE, ${T('PET')}.TYPE_ID,`, + ` ${F('row')}(${T('OWNER')}.ID, ${T('OWNER')}.FIRST_NAME, ${T('OWNER')}.LAST_NAME, ${T('OWNER')}.ADDRESS, ${T('OWNER')}.TELEPHONE,`, + ` ${F('row')}(${T('CITY')}.ID, ${T('CITY')}.NAME).${F('mapping')}(${T('City')}::new)).${F('mapping')}(${T('Owner')}::new))`, + ` .${F('from')}(${T('PET')})`, + ` .${F('join')}(${T('OWNER')}).${F('on')}(${T('PET')}.OWNER_ID.${F('eq')}(${T('OWNER')}.ID))`, + ` .${F('join')}(${T('CITY')}).${F('on')}(${T('OWNER')}.CITY_ID.${F('eq')}(${T('CITY')}.ID))`, + ` .${F('where')}(${T('PET')}.ID.${F('gt')}(base).${F('and')}(${T('PET')}.ID.${F('le')}(base + rows)))`, + ` .${F('fetch')}(${T('Records')}.${F('mapping')}(${T('Pet')}::new));`, +].join('\n'); +const CODE_JOIN_EXPOSED = [ + `${F('transaction')}(database) {`, + ` (${T('Pets')} ${F('innerJoin')} ${T('Owners')} ${F('innerJoin')} ${T('Cities')})`, + ` .${F('selectAll')}()`, + ` .${F('where')} { (${T('Pets')}.id ${F('greater')} base) ${K('and')} (${T('Pets')}.id ${F('lessEq')} base + rows) }`, + ` .${F('map')} { it.${F('toPet')}() }`, + `}`, +].join('\n'); +const CODE_JOIN_EXPOSED_DAO = [ + `${F('transaction')}(database) {`, + ` ${T('PetDao')}.${F('wrapRows')}(`, + ` ${T('Pets')}.${F('selectAll')}()`, + ` .${F('where')} { (${T('Pets')}.id ${F('greater')} base) ${K('and')} (${T('Pets')}.id ${F('lessEq')} base + rows) })`, + ` .${F('with')}(${T('PetDao')}::owner, ${T('OwnerDao')}::city) ${C('// 2 extra batched SELECT ... IN queries')}`, + ` .${F('map')} { it.${F('toPet')}() }`, + `}`, +].join('\n'); +const CODE_JOIN_JIMMER = [ + `${T('PetTable')} table = ${T('PetTable')}.$;`, + `${K('return')} sqlClient.${F('createQuery')}(table)`, + ` .${F('where')}(table.${F('id')}().${F('gt')}(base)).${F('where')}(table.${F('id')}().${F('le')}(base + rows))`, + ` .${F('select')}(table.${F('fetch')}(`, + ` ${T('PetFetcher')}.$.${F('allScalarFields')}()`, + ` .${F('owner')}(${T('OwnerFetcher')}.$.${F('allScalarFields')}()`, + ` .${F('city')}(${T('CityFetcher')}.$.${F('allScalarFields')}())))) ${C('// fetchers -> 2 batched loads')}`, + ` .${F('execute')}();`, +].join('\n'); +const SQL_JOIN_EXPOSED_DAO = [ + `${QC('-- 1) main pet query')}`, + `${QK('SELECT')} p.id, p.name, p.birth_date, p.type_id, p.owner_id`, + `${QK('FROM')} pet ${QK('WHERE')} p.id > ${QQ('?')} ${QK('AND')} p.id <= ${QQ('?')}`, + ``, + `${QC('-- 2) batched owners')}`, + `${QK('SELECT')} o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id`, + `${QK('FROM')} owner ${QK('WHERE')} o.id ${QK('IN')} (${QQ('?')}, ${QQ('?')}, ...)`, + ``, + `${QC('-- 3) batched cities')}`, + `${QK('SELECT')} c.id, c.name ${QK('FROM')} city ${QK('WHERE')} c.id ${QK('IN')} (${QQ('?')}, ${QQ('?')}, ...)`, +].join('\n'); +const SQL_JOIN_JIMMER = [ + `${QC('-- 1) main pet query (owner_id kept as FK for the batch load)')}`, + `${QK('SELECT')} p.id, p.name, p.birth_date, p.owner_id`, + `${QK('FROM')} pet ${QK('WHERE')} p.id > ${QQ('?')} ${QK('AND')} p.id <= ${QQ('?')}`, + ``, + `${QC('-- 2) batched owners')}`, + `${QK('SELECT')} o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id`, + `${QK('FROM')} owner ${QK('WHERE')} o.id ${QK('IN')} (${QQ('?')}, ${QQ('?')}, ...)`, + ``, + `${QC('-- 3) batched cities')}`, + `${QK('SELECT')} c.id, c.name ${QK('FROM')} city ${QK('WHERE')} c.id ${QK('IN')} (${QQ('?')}, ${QQ('?')}, ...)`, +].join('\n'); const CODE_PROJECTION = [ `${K('data class')} ${T('PetRow')}(${K('val')} petName: ${T('String')}, ${K('val')} ownerLastName: ${T('String')}, ${K('val')} cityName: ${T('String')})`, @@ -207,15 +467,130 @@ const SQL_PROJECTION = [ `${QK('INNER JOIN')} city c ${QK('ON')} o.city_id = c.id`, `${QK('WHERE')} o.city_id = ${QQ('?')}`, ].join('\n'); +const CODE_PROJECTION_JDBC = [ + `${K('try')} (${K('var')} ps = connection.${F('prepareStatement')}(`, + ` ${S('"SELECT p.name, o.last_name, c.name FROM pet p"')}`, + ` + ${S('" JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id"')}`, + ` + ${S('" WHERE o.city_id = ?"')})) {`, + ` ps.${F('setLong')}(${N('1')}, cityId);`, + ` ${K('try')} (${K('var')} rs = ps.${F('executeQuery')}()) {`, + ` ${T('List')}<${T('PetRow')}> rows = ${K('new')} ${T('ArrayList')}<>();`, + ` ${K('while')} (rs.${F('next')}()) rows.${F('add')}(${K('new')} ${T('PetRow')}(rs.${F('getString')}(${N('1')}), rs.${F('getString')}(${N('2')}), rs.${F('getString')}(${N('3')})));`, + ` ${K('return')} rows;`, + ` }`, + `}`, +].join('\n'); +const CODE_PROJECTION_HIBERNATE = [ + `${K('return')} sessionFactory.${F('fromSession')}(session -> session`, + ` .${F('createSelectionQuery')}(`, + ` ${S('"select p.name, o.lastName, c.name from Pet p join p.owner o join o.city c where c.id = :cityId"')},`, + ` ${T('PetRow')}.${K('class')})`, + ` .${F('setParameter')}(${S('"cityId"')}, cityId)`, + ` .${F('getResultList')}());`, +].join('\n'); +const CODE_PROJECTION_JOOQ = [ + `${K('return')} ctx.${F('select')}(${T('PET')}.NAME, ${T('OWNER')}.LAST_NAME, ${T('CITY')}.NAME)`, + ` .${F('from')}(${T('PET')})`, + ` .${F('join')}(${T('OWNER')}).${F('on')}(${T('PET')}.OWNER_ID.${F('eq')}(${T('OWNER')}.ID))`, + ` .${F('join')}(${T('CITY')}).${F('on')}(${T('OWNER')}.CITY_ID.${F('eq')}(${T('CITY')}.ID))`, + ` .${F('where')}(${T('OWNER')}.CITY_ID.${F('eq')}(cityId))`, + ` .${F('fetch')}(${T('Records')}.${F('mapping')}(${T('PetRow')}::new));`, +].join('\n'); +const CODE_PROJECTION_EXPOSED = [ + `${F('transaction')}(database) {`, + ` (${T('Pets')} ${F('innerJoin')} ${T('Owners')} ${F('innerJoin')} ${T('Cities')})`, + ` .${F('select')}(${T('Pets')}.name, ${T('Owners')}.lastName, ${T('Cities')}.name)`, + ` .${F('where')} { ${T('Owners')}.cityId ${F('eq')} cityId }`, + ` .${F('map')} { ${T('PetRow')}(it[${T('Pets')}.name], it[${T('Owners')}.lastName], it[${T('Cities')}.name]) }`, + `}`, +].join('\n'); +const CODE_PROJECTION_EXPOSED_DAO = [ + `${C('// Exposed DAO drops to the same DSL query for projections')}`, + `${F('transaction')}(database) {`, + ` (${T('Pets')} ${F('innerJoin')} ${T('Owners')} ${F('innerJoin')} ${T('Cities')})`, + ` .${F('select')}(${T('Pets')}.name, ${T('Owners')}.lastName, ${T('Cities')}.name)`, + ` .${F('where')} { ${T('Owners')}.cityId ${F('eq')} ${T('EntityID')}(cityId, ${T('Cities')}) }`, + ` .${F('map')} { ${T('PetRow')}(it[${T('Pets')}.name], it[${T('Owners')}.lastName], it[${T('Cities')}.name]) }`, + `}`, +].join('\n'); +const CODE_PROJECTION_JIMMER = [ + `${T('PetTable')} table = ${T('PetTable')}.$;`, + `${K('return')} sqlClient.${F('createQuery')}(table)`, + ` .${F('where')}(table.${F('owner')}().${F('city')}().${F('id')}().${F('eq')}(cityId))`, + ` .${F('select')}(table.${F('name')}(), table.${F('owner')}().${F('lastName')}(), table.${F('owner')}().${F('city')}().${F('name')}())`, + ` .${F('execute')}();`, +].join('\n'); const CODE_BATCH = [ `${K('val')} ids = ${F('transaction')} {`, - ` visits.${F('insertAndFetchIds')}(newVisits) ${C('// 100 visits, one atomic batch')}`, + ` visits.${F('insertAndFetchIds')}(newVisits) ${C('// 100 visits, one prepared INSERT, batched')}`, `}`, ].join('\n'); +const CODE_BATCH_JDBC = [ + `${K('try')} (${K('var')} ps = connection.${F('prepareStatement')}(`, + ` ${S('"INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?)"')},`, + ` ${T('Statement')}.RETURN_GENERATED_KEYS)) {`, + ` ${K('for')} (${K('int')} i = ${N('0')}; i < ${T('BATCH_SIZE')}; i++) {`, + ` ps.${F('setLong')}(${N('1')}, ...); ps.${F('setObject')}(${N('2')}, ...); ps.${F('setString')}(${N('3')}, ...);`, + ` ps.${F('addBatch')}();`, + ` }`, + ` ps.${F('executeBatch')}();`, + ` ${K('try')} (${K('var')} keys = ps.${F('getGeneratedKeys')}()) {`, + ` ${K('while')} (keys.${F('next')}()) ids.${F('add')}(keys.${F('getLong')}(${N('1')}));`, + ` }`, + `}`, +].join('\n'); +const CODE_BATCH_HIBERNATE = [ + `sessionFactory.${F('fromTransaction')}(session -> {`, + ` ${K('for')} (${K('int')} i = ${N('0')}; i < ${T('BATCH_SIZE')}; i++) {`, + ` ${T('Pet')} pet = session.${F('getReference')}(${T('Pet')}.${K('class')}, ...); ${C('// proxy, no SELECT')}`, + ` session.${F('persist')}(${K('new')} ${T('Visit')}(pet, ..., ...));`, + ` }`, + ` session.${F('flush')}(); ${C('// ids pre-fetched from visit_seq')}`, + ` ${K('return')} visits.${F('stream')}().${F('map')}(${T('Visit')}::getId).${F('toList')}();`, + `});`, +].join('\n'); +const CODE_BATCH_JOOQ = [ + `${K('var')} insert = ctx.${F('insertInto')}(${T('VISIT')}, ${T('VISIT')}.PET_ID, ${T('VISIT')}.VISIT_DATE, ${T('VISIT')}.DESCRIPTION);`, + `${K('for')} (${K('int')} i = ${N('0')}; i < ${T('BATCH_SIZE')}; i++) {`, + ` insert = insert.${F('values')}(..., ..., ...); ${C('// one VALUES tuple appended per row')}`, + `}`, + `${K('return')} insert.${F('returning')}(${T('VISIT')}.ID).${F('fetch')}().${F('map')}(r -> r.${F('get')}(${T('VISIT')}.ID));`, +].join('\n'); +const CODE_BATCH_EXPOSED = [ + `${T('Visits')}.${F('batchInsert')}(${N('0')} ${K('until')} ${T('BATCH_SIZE')}, shouldReturnGeneratedValues = ${K('true')}) { i ->`, + ` ${K('this')}[${T('Visits')}.petId] = ...`, + ` ${K('this')}[${T('Visits')}.visitDate] = ...`, + ` ${K('this')}[${T('Visits')}.description] = ...`, + `}.${F('map')} { it[${T('Visits')}.id] } ${C('// Exposed batchInsert = JDBC addBatch, not multi-row VALUES')}`, +].join('\n'); +const CODE_BATCH_EXPOSED_DAO = [ + `${K('val')} daos = (${N('0')} ${K('until')} ${T('BATCH_SIZE')}).${F('map')} {`, + ` ${T('VisitDao')}.${F('new')} { petId = ...; visitDate = ...; description = ... }`, + `}`, + `daos.${F('map')} { it.id.value } ${C('// reading ids flushes the pending inserts as a batch')}`, +].join('\n'); +const CODE_BATCH_JIMMER = [ + `${T('List')}<${T('Visit')}> drafts = ...; ${C('// 100x VisitDraft.$.produce(d -> { d.setPet(makeIdOnly(..)); .. })')}`, + `sqlClient.${F('getEntities')}()`, + ` .${F('saveEntitiesCommand')}(drafts)`, + ` .${F('setMode')}(${T('SaveMode')}.INSERT_ONLY)`, + ` .${F('execute')}(connection); ${C('// JDBC batch, ids from IDENTITY')}`, +].join('\n'); const SQL_BATCH = [ `${QK('INSERT INTO')} visit (pet_id, visit_date, description)`, - `${QK('VALUES')} (${QQ('?')}, ${QQ('?')}, ${QQ('?')})`, + `${QK('VALUES')} (${QQ('?')}, ${QQ('?')}, ${QQ('?')}) ${QC('-- one prepared statement, addBatch/executeBatch x100')}`, +].join('\n'); +const SQL_BATCH_JOOQ = [ + `${QK('INSERT INTO')} visit (pet_id, visit_date, description)`, + `${QK('VALUES')} (${QQ('?')}, ${QQ('?')}, ${QQ('?')}), (${QQ('?')}, ${QQ('?')}, ${QQ('?')}), ... ${QC('-- 100 tuples, one statement')}`, + `${QK('RETURNING')} visit.id`, +].join('\n'); +const SQL_BATCH_HIBERNATE = [ + `${QK('SELECT')} nextval('visit_seq') ${QC('-- pooled sequence, ~2 calls for 100 rows')}`, + ``, + `${QK('INSERT INTO')} visit (pet_id, visit_date, description, id)`, + `${QK('VALUES')} (${QQ('?')}, ${QQ('?')}, ${QQ('?')}, ${QQ('?')}) ${QC('-- batched x100, id assigned client-side')}`, ].join('\n'); const CODE_UPDATE = [ @@ -232,18 +607,84 @@ const CODE_UPDATE = [ ``, `${F('transaction')} {`, ` ${K('val')} owner = owners.${F('getById')}(id)`, - ` owners.${F('update')}(owner.${F('copy')}(telephone = newTelephone))`, + ` owners.${F('update')}(owner.${F('copy')}(telephone = newTelephone)) ${C('// only the changed column is written')}`, `}`, ].join('\n'); const SQL_UPDATE = [ - `${QK('SELECT')} ocr.id, ocr.first_name, ocr.last_name, ocr.address, ocr.telephone, ocr.city_id`, - `${QK('FROM')} owner ocr`, - `${QK('WHERE')} ocr.id = ${QQ('?')}`, + `${QK('SELECT')} o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id`, + `${QK('FROM')} owner o`, + `${QK('WHERE')} o.id = ${QQ('?')}`, ``, `${QK('UPDATE')} owner`, `${QK('SET')} telephone = ${QQ('?')}`, `${QK('WHERE')} id = ${QQ('?')}`, ].join('\n'); +const CODE_UPDATE_JDBC = [ + `connection.${F('setAutoCommit')}(${K('false')});`, + `${T('String')} phone;`, + `${K('try')} (${K('var')} ps = connection.${F('prepareStatement')}(`, + ` ${S('"SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = ?"')})) {`, + ` ps.${F('setLong')}(${N('1')}, id);`, + ` ${K('var')} rs = ps.${F('executeQuery')}(); rs.${F('next')}();`, + ` phone = ${T('Params')}.${F('toggleTelephone')}(rs.${F('getString')}(${N('5')}));`, + ` owner = ${K('new')} ${T('Owner')}(rs.${F('getLong')}(${N('1')}), ..., phone, ${K('null')});`, + `}`, + `${K('try')} (${K('var')} ps = connection.${F('prepareStatement')}(${S('"UPDATE owner SET telephone = ? WHERE id = ?"')})) {`, + ` ps.${F('setString')}(${N('1')}, phone); ps.${F('setLong')}(${N('2')}, id); ps.${F('executeUpdate')}();`, + `}`, + `connection.${F('commit')}();`, +].join('\n'); +const CODE_UPDATE_HIBERNATE = [ + `${A('@Entity')} ${A('@Table')}(name = ${S('"owner"')}) ${A('@DynamicUpdate')} ${C('// write only the changed columns')}`, + `${K('class')} ${T('Owner')} {`, + ` ${A('@Id')} ${A('@GeneratedValue')}(strategy = IDENTITY) ${K('private')} ${T('Long')} id;`, + ` ${A('@Column')}(name = ${S('"first_name"')}) ${K('private')} ${T('String')} firstName;`, + ` ${A('@Column')}(name = ${S('"last_name"')}) ${K('private')} ${T('String')} lastName;`, + ` ${A('@Column')}(name = ${S('"address"')}) ${K('private')} ${T('String')} address;`, + ` ${A('@Column')}(name = ${S('"telephone"')}) ${K('private')} ${T('String')} telephone;`, + ` ${A('@ManyToOne')}(fetch = LAZY) ${A('@JoinColumn')}(name = ${S('"city_id"')}) ${K('private')} ${T('City')} city; ${C('// lazy: city not read')}`, + ` ${A('@OneToMany')}(mappedBy = ${S('"owner"')}) ${K('private')} ${T('List')}<${T('Pet')}> pets;`, + ``, + ` ${K('public')} ${T('String')} ${F('getTelephone')}() { ${K('return')} telephone; }`, + ` ${K('public')} ${K('void')} ${F('setTelephone')}(${T('String')} telephone) { ${K('this')}.telephone = telephone; }`, + ` ${C('// Hibernate maps via field access; getId()/getPets() added where the app needs them')}`, + `}`, + ``, + `sessionFactory.${F('fromTransaction')}(session -> {`, + ` ${T('Owner')} owner = session.${F('find')}(${T('Owner')}.${K('class')}, id);`, + ` owner.${F('setTelephone')}(${T('Params')}.${F('toggleTelephone')}(owner.${F('getTelephone')}()));`, + ` ${K('return')} owner; ${C('// dirty-checking flushes only telephone')}`, + `});`, +].join('\n'); +const CODE_UPDATE_JOOQ = [ + `${K('return')} ctx.${F('transactionResult')}(tx -> {`, + ` ${K('var')} record = ${T('DSL')}.${F('using')}(tx).${F('fetchOne')}(${T('OWNER')}, ${T('OWNER')}.ID.${F('eq')}(id));`, + ` record.${F('setTelephone')}(${T('Params')}.${F('toggleTelephone')}(record.${F('getTelephone')}()));`, + ` record.${F('store')}(); ${C('// UpdatableRecord.store() updates only the changed field')}`, + ` ${K('return')} record.${F('getId')}();`, + `});`, +].join('\n'); +const CODE_UPDATE_EXPOSED = [ + `${F('transaction')}(database) {`, + ` ${K('val')} row = ${T('Owners')}.${F('selectAll')}().${F('where')} { ${T('Owners')}.id ${F('eq')} id }.${F('single')}()`, + ` ${K('val')} phone = ${T('Params')}.${F('toggleTelephone')}(row[${T('Owners')}.telephone])`, + ` ${T('Owners')}.${F('update')}({ ${T('Owners')}.id ${F('eq')} id }) { it[${T('Owners')}.telephone] = phone }`, + `}`, +].join('\n'); +const CODE_UPDATE_EXPOSED_DAO = [ + `${F('transaction')}(database) {`, + ` ${K('val')} dao = ${T('OwnerDao')}.${F('findById')}(id) ?: ${F('error')}(${S('"owner not found"')})`, + ` dao.telephone = ${T('Params')}.${F('toggleTelephone')}(dao.telephone) ${C('// dirty tracking flushes only telephone')}`, + `}`, +].join('\n'); +const CODE_UPDATE_JIMMER = [ + `${T('Owner')} owner = sqlClient.${F('createQuery')}(${T('OwnerTable')}.$)`, + ` .${F('where')}(${T('OwnerTable')}.$.${F('id')}().${F('eq')}(id)).${F('select')}(${T('OwnerTable')}.$).${F('execute')}(connection).${F('getFirst')}();`, + `${T('Owner')} updated = ${T('OwnerDraft')}.$.${F('produce')}(owner, d -> d.${F('setTelephone')}(${T('Params')}.${F('toggleTelephone')}(owner.${F('telephone')}())));`, + `sqlClient.${F('getEntities')}().${F('saveCommand')}(updated)`, + ` .${F('setMode')}(${T('SaveMode')}.UPDATE_ONLY) ${C('// only the draft-modified column')}`, + ` .${F('execute')}(connection);`, +].join('\n'); const CODE_GRAPH_STORM = [ `${K('val')} result = pets.${F('select')}()`, @@ -269,8 +710,6 @@ const CODE_GRAPH_HIBERNATE = [ ` ${T('Owner')}.${K('class')})`, ` .${F('setParameter')}(${S('"cityId"')}, cityId)`, ` .${F('getResultList')}());`, - ``, - `${C('// plus the @OneToMany(mappedBy = "owner") collection on the 126-line entity model')}`, ].join('\n'); const CODE_GRAPH_JOOQ = [ @@ -294,6 +733,91 @@ const CODE_GRAPH_JOOQ = [ ` });`, ].join('\n'); +const CODE_GRAPH_JDBC = [ + `${T('Map')}<${T('Long')}, ${T('Owner')}> owners = ${K('new')} ${T('LinkedHashMap')}<>();`, + `${T('Map')}<${T('Long')}, ${T('List')}<${T('Pet')}>> pets = ${K('new')} ${T('LinkedHashMap')}<>();`, + `${K('while')} (rs.${F('next')}()) {`, + ` ${K('long')} ownerId = rs.${F('getLong')}(${N('1')});`, + ` ${T('Owner')} owner = owners.${F('get')}(ownerId);`, + ` ${K('if')} (owner == ${K('null')}) {`, + ` owner = ${K('new')} ${T('Owner')}(ownerId, ..., ${K('new')} ${T('City')}(rs.${F('getLong')}(${N('6')}), rs.${F('getString')}(${N('7')})));`, + ` owners.${F('put')}(ownerId, owner); pets.${F('put')}(ownerId, ${K('new')} ${T('ArrayList')}<>());`, + ` }`, + ` pets.${F('get')}(ownerId).${F('add')}(${K('new')} ${T('Pet')}(rs.${F('getLong')}(${N('8')}), ..., owner));`, + `}`, + `${C('// then zip owners + pets into List')}`, +].join('\n'); +const CODE_GRAPH_EXPOSED = [ + `${F('transaction')}(database) {`, + ` (${T('Owners')} ${F('innerJoin')} ${T('Cities')} ${F('innerJoin')} ${T('Pets')})`, + ` .${F('selectAll')}()`, + ` .${F('where')} { ${T('Owners')}.cityId ${F('eq')} cityId }`, + ` .${F('orderBy')}(${T('Owners')}.id)`, + ` .${F('groupIntoOwners')}() ${C('// in-memory LinkedHashMap grouping')}`, + `}`, +].join('\n'); +const CODE_GRAPH_EXPOSED_DAO = [ + `${F('transaction')}(database) {`, + ` ${T('OwnerDao')}.${F('find')} { ${T('Owners')}.cityId ${F('eq')} ${T('EntityID')}(cityId, ${T('Cities')}) }`, + ` .${F('orderBy')}(${T('Owners')}.id ${K('to')} ${T('SortOrder')}.ASC)`, + ` .${F('with')}(${T('OwnerDao')}::city, ${T('OwnerDao')}::pets) ${C('// pets = batched SELECT ... WHERE owner_id IN (...)')}`, + ` .${F('map')} { ${T('OwnerWithPets')}(it.${F('toOwner')}(), it.pets.${F('map')} { p -> p.${F('toPet')}() }) }`, + `}`, +].join('\n'); +const CODE_GRAPH_JIMMER = [ + `${T('OwnerTable')} table = ${T('OwnerTable')}.$;`, + `${K('return')} sqlClient.${F('createQuery')}(table)`, + ` .${F('where')}(table.${F('city')}().${F('id')}().${F('eq')}(cityId))`, + ` .${F('orderBy')}(table.${F('id')}().${F('asc')}())`, + ` .${F('select')}(table.${F('fetch')}(`, + ` ${T('OwnerFetcher')}.$.${F('allScalarFields')}()`, + ` .${F('city')}(${T('CityFetcher')}.$.${F('allScalarFields')}())`, + ` .${F('pets')}(${T('PetFetcher')}.$.${F('allScalarFields')}()))) ${C('// pets = batched WHERE owner_id IN (...)')}`, + ` .${F('execute')}();`, +].join('\n'); +const SQL_GRAPH_HIBERNATE = [ + `${QK('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`, + `${QK('FROM')} owner o`, + `${QK('JOIN')} pet p ${QK('ON')} o.id = p.owner_id`, + `${QK('JOIN')} city c ${QK('ON')} c.id = o.city_id`, + `${QK('WHERE')} o.city_id = ${QQ('?')} ${QC('-- DISTINCT collapses the owner x pet cartesian, no ORDER BY')}`, +].join('\n'); +const SQL_GRAPH_JOOQ = [ + `${QK('SELECT')} owner.id, owner.first_name, owner.last_name, owner.address, owner.telephone,`, + ` city.id, city.name,`, + ` (${QK('SELECT')} jsonb_agg(jsonb_build_array(p.id, p.name, p.birth_date, p.type_id))`, + ` ${QK('FROM')} pet p ${QK('WHERE')} p.owner_id = owner.id) ${QK('AS')} pets ${QC('-- correlated MULTISET, no pet join')}`, + `${QK('FROM')} owner`, + `${QK('JOIN')} city ${QK('ON')} owner.city_id = city.id`, + `${QK('WHERE')} owner.city_id = ${QQ('?')}`, + `${QK('ORDER BY')} owner.id`, +].join('\n'); +const SQL_GRAPH_EXPOSED_DAO = [ + `${QC('-- 1) main owners query')}`, + `${QK('SELECT')} owner.id, ..., owner.city_id ${QK('FROM')} owner ${QK('WHERE')} owner.city_id = ${QQ('?')} ${QK('ORDER BY')} owner.id`, + ``, + `${QC('-- 2) batched cities')}`, + `${QK('SELECT')} city.id, city.name ${QK('FROM')} city ${QK('WHERE')} city.id ${QK('IN')} (${QQ('?')}, ...)`, + ``, + `${QC('-- 3) batched pets (the collection)')}`, + `${QK('SELECT')} pet.id, pet.name, pet.birth_date, pet.type_id, pet.owner_id`, + `${QK('FROM')} pet ${QK('WHERE')} pet.owner_id ${QK('IN')} (${QQ('?')}, ${QQ('?')}, ...)`, +].join('\n'); +const SQL_GRAPH_JIMMER = [ + `${QC('-- 1) main owners query (city().id() folds to owner.city_id)')}`, + `${QK('SELECT')} o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id`, + `${QK('FROM')} owner o ${QK('WHERE')} o.city_id = ${QQ('?')} ${QK('ORDER BY')} o.id`, + ``, + `${QC('-- 2) batched cities')}`, + `${QK('SELECT')} c.id, c.name ${QK('FROM')} city c ${QK('WHERE')} c.id ${QK('IN')} (${QQ('?')}, ...)`, + ``, + `${QC('-- 3) batched pets collection (the OneToMany)')}`, + `${QK('SELECT')} p.owner_id, p.id, p.name, p.birth_date, p.type_id`, + `${QK('FROM')} pet p ${QK('WHERE')} p.owner_id ${QK('IN')} (${QQ('?')}, ${QQ('?')}, ...)`, +].join('\n'); + const MATRIX_LIBS = ['jdbc', 'storm', 'hibernate', 'jooq', 'exposed', 'exposedDao', 'jimmer']; // Green while close to the fastest framework in the row, then yellow, orange, red as the gap grows. @@ -421,15 +945,15 @@ ${navHtml('benchmarks')}

    PostgreSQL 17 over TCP · JMH · Storm 1.13.0 · measured 2026-07-16

    -
    5 of 8workloads are won by Storm, more than any other ORM.
    -
    5.6%is the most Storm trails the fastest ORM on any workload. It remains close to the lead throughout.
    -
    62% loweroverhead than jOOQ (the next-fastest ORM) relative to raw JDBC across all workloads.
    +
    5 of 8workloads where Storm is the fastest ORM.
    +
    5.6%is the most Storm trails the fastest ORM on any workload.
    +
    62% loweroverhead than jOOQ (2nd fastest ORM) relative to raw JDBC.

    At a glance

    -

    Seven implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. Mean latency per operation, lower is better. Cells are tinted by distance from the fastest framework in the row, green through red. Percentages are overhead over raw JDBC. Raw JDBC is the reference floor.

    +

    Seven implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. Mean latency per operation, lower is better. Cells are tinted by distance from the fastest framework in the row, green through red. Percentages are overhead over raw JDBC. Raw JDBC is the baseline.

    ${matrixHtml()} -

    Every library has strong rows, but Storm's is the only column that never runs hot. On every workload Storm is either the fastest framework or close behind it, while every alternative has at least one workload where it costs a third more than the best, and most cost far more than that. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.

    +

    Storm is the fastest framework on average across the eight workloads. On a few, another library edges it: Hibernate on the single-row lookup and the read-modify-update, jOOQ on the batch insert, where its single multi-row statement beats the batched single-row insert the others send. Even then, Storm stays within about six percent of the fastest framework. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.

    Per-workload charts: the same numbers with their reported error @@ -440,38 +964,90 @@ ${charts}

    The code being measured

    -

    Numbers without code invite tuned-benchmark suspicion, so here is what each workload runs, trimmed of harness plumbing. Toggle Show SQL to see the exact statement Storm puts on the wire. The full sources for all seven implementations are in the benchmark repository.

    +

    Numbers without code invite tuned-benchmark suspicion, so here is what each workload runs in every library, trimmed of harness plumbing. Pick a library from the selector; toggle Show SQL to see the exact statement it puts on the wire. The full sources for all seven implementations are in the benchmark repository.

    ${modelLocHtml()} ${locHtml()}

    The model

    -

    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. There are no proxies, no session lifecycle, and nothing to configure.

    - ${editor({file: 'Entities.kt', tag: 'Kotlin', code: CODE_ENTITIES})} +

    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. Switch the selector to compare it against the JPA entities, table objects and interfaces the other libraries declare for the same five tables.

    + ${editor({variants: [ + {label: 'Storm', file: 'Entities.kt', tag: 'Kotlin', code: CODE_ENTITIES, selected: true}, + {label: 'JDBC', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JDBC}, + {label: 'Hibernate', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_HIBERNATE}, + {label: 'jOOQ', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JOOQ}, + {label: 'Exposed', file: 'Tables.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED}, + {label: 'Exposed DAO', file: 'Entities.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED_DAO}, + {label: 'Jimmer', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_JIMMER}, + ]})}

    Primary key lookup

    - ${editor({file: 'singleRowById', tag: 'Kotlin', code: CODE_SINGLE, sql: SQL_SINGLE})} + ${editor({file: 'singleRowById', sql: SQL_SINGLE, variants: [ + {label: 'Storm', tag: 'Kotlin', code: CODE_SINGLE, selected: true}, + {label: 'JDBC', tag: 'Java', code: CODE_SINGLE_JDBC}, + {label: 'Hibernate', tag: 'Java', code: CODE_SINGLE_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_SINGLE_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_SINGLE_JIMMER}, + ]})}

    Three-table join

    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.

    - ${editor({file: 'joinWithMapping', tag: 'Kotlin', code: CODE_JOIN, sql: SQL_JOIN})} + ${editor({file: 'joinWithMapping', sql: SQL_JOIN, variants: [ + {label: 'Storm', tag: 'Kotlin', code: CODE_JOIN, selected: true}, + {label: 'JDBC', tag: 'Java', code: CODE_JOIN_JDBC}, + {label: 'Hibernate', tag: 'Java', code: CODE_JOIN_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_JOIN_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_JOIN_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_JOIN_EXPOSED_DAO, sql: SQL_JOIN_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_JOIN_JIMMER, sql: SQL_JOIN_JIMMER}, + ]})}

    Projection

    A template picks three columns across the graph; the metamodel keeps every path compile-checked.

    - ${editor({file: 'projection', tag: 'Kotlin', code: CODE_PROJECTION, sql: SQL_PROJECTION})} + ${editor({file: 'projection', sql: SQL_PROJECTION, variants: [ + {label: 'Storm', tag: 'Kotlin', code: CODE_PROJECTION, selected: true}, + {label: 'JDBC', tag: 'Java', code: CODE_PROJECTION_JDBC}, + {label: 'Hibernate', tag: 'Java', code: CODE_PROJECTION_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_PROJECTION_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_PROJECTION_JIMMER}, + ]})}

    Batch insert

    - ${editor({file: 'batchInsert', tag: 'Kotlin', code: CODE_BATCH, sql: SQL_BATCH})} + ${editor({file: 'batchInsert', variants: [ + {label: 'Storm', tag: 'Kotlin', code: CODE_BATCH, sql: SQL_BATCH, selected: true}, + {label: 'JDBC', tag: 'Java', code: CODE_BATCH_JDBC, sql: SQL_BATCH}, + {label: 'Hibernate', tag: 'Java', code: CODE_BATCH_HIBERNATE, sql: SQL_BATCH_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_BATCH_JOOQ, sql: SQL_BATCH_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_BATCH_EXPOSED, sql: SQL_BATCH}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_BATCH_EXPOSED_DAO, sql: SQL_BATCH}, + {label: 'Jimmer', tag: 'Java', code: CODE_BATCH_JIMMER, sql: SQL_BATCH}, + ]})}

    Read, modify, update

    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.

    - ${editor({file: 'updateById', tag: 'Kotlin', code: CODE_UPDATE, sql: SQL_UPDATE})} + ${editor({file: 'updateById', sql: SQL_UPDATE, variants: [ + {label: 'Storm', tag: 'Kotlin', code: CODE_UPDATE, selected: true}, + {label: 'JDBC', tag: 'Java', code: CODE_UPDATE_JDBC}, + {label: 'Hibernate', tag: 'Java', code: CODE_UPDATE_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_UPDATE_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_UPDATE_JIMMER}, + ]})}

    Object graph

    One query, grouped during hydration. Repeated owners deduplicate to the same instance, so grouping is an identity operation, not a hash of every field. Switch the variant to see the code the other libraries needed for the same result.

    - ${editor({file: 'objectGraph', tag: 'Kotlin', sql: SQL_GRAPH, variants: [ - {label: 'Storm', code: CODE_GRAPH_STORM, selected: true}, - {label: 'Hibernate', code: CODE_GRAPH_HIBERNATE}, - {label: 'jOOQ', code: CODE_GRAPH_JOOQ}, + ${editor({file: 'objectGraph', sql: SQL_GRAPH, variants: [ + {label: 'Storm', tag: 'Kotlin', code: CODE_GRAPH_STORM, selected: true}, + {label: 'JDBC', tag: 'Java', code: CODE_GRAPH_JDBC}, + {label: 'Hibernate', tag: 'Java', code: CODE_GRAPH_HIBERNATE, sql: SQL_GRAPH_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_GRAPH_JOOQ, sql: SQL_GRAPH_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED_DAO, sql: SQL_GRAPH_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_GRAPH_JIMMER, sql: SQL_GRAPH_JIMMER}, ]})}

    Method and fairness

    diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 359d70022..3d8597dfc 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -364,27 +364,27 @@ export default function Home() { // Pick your ORM: the feature cards show Storm-vs-X measured figures (run of 2026-07-16). const VS = { hibernate: { - speed: ['17%', 'faster than Hibernate', 'Averaged across 8 workloads.'], + speed: ['17%', 'faster than Hibernate', 'On average across 8 workloads.'], entities: ['72%', 'fewer entity lines', 'The five-table model: 29 lines in Storm, 105 in Hibernate.'], queries: ['19%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 123 in Hibernate, with no query strings.'], }, jooq: { - speed: ['15%', 'faster than jOOQ', 'Averaged across 8 workloads.'], + speed: ['15%', 'faster than jOOQ', 'On average across 8 workloads.'], entities: ['29 lines', 'instead of manual mapping', 'jOOQ maps results by hand into DTOs; Storm turns one 29-line model into typed rows everywhere.'], queries: ['24%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 131 in jOOQ, no hand-written row mapping.'], }, exposed: { - speed: ['18%', 'faster than Exposed', 'Averaged across 8 workloads.'], + speed: ['18%', 'faster than Exposed', 'On average across 8 workloads.'], entities: ['43%', 'fewer entity lines', 'The five-table model: 29 lines in Storm, 51 lines of Exposed table objects and data classes.'], queries: ['22%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 128 in Exposed, no hand-written row mapping.'], }, exposedDao: { - speed: ['38%', 'faster than Exposed DAO', 'Averaged across 8 workloads.'], + speed: ['38%', 'faster than Exposed DAO', 'On average across 8 workloads.'], entities: ['58%', 'fewer entity lines', 'One data class per table in Storm; Exposed DAO needs the table object, the DAO class and a DTO.'], queries: ['19%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 124 in Exposed DAO.'], }, jimmer: { - speed: ['25%', 'faster than Jimmer', 'Averaged across 8 workloads.'], + speed: ['25%', 'faster than Jimmer', 'On average across 8 workloads.'], entities: ['47%', 'fewer entity lines', 'The five-table model: 29 lines of data classes in Storm, 55 lines of interfaces in Jimmer.'], queries: ['31%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 144 in Jimmer.'], },