diff --git a/website/src/components/tutorial/tutorialTheme.js b/website/src/components/tutorial/tutorialTheme.js index a34e564f8..e7b2f0bb5 100644 --- a/website/src/components/tutorial/tutorialTheme.js +++ b/website/src/components/tutorial/tutorialTheme.js @@ -102,7 +102,7 @@ export function editor({file, tag, code, sql, copy, variants}) { const hasVariantSql = variants && variants.some((v) => v.sql); const hasSql = Boolean(sql) || hasVariantSql; const sqlBtn = hasSql - ? `Show SQL` + ? `Show SQL` : ''; const copyText = copy === true ? toPlain(variants ? variants[selectedIdx].code : code) : copy; @@ -204,10 +204,15 @@ export function wireSqlToggles() { const onClick = () => { const on = ed.classList.toggle('show-sql'); btn.classList.toggle('on', on); + btn.setAttribute('aria-expanded', on ? 'true' : 'false'); if (txt) txt.textContent = on ? 'Hide SQL' : 'Show SQL'; }; + const onKey = (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } + }; btn.addEventListener('click', onClick); - cleanups.push(() => btn.removeEventListener('click', onClick)); + btn.addEventListener('keydown', onKey); + cleanups.push(() => { btn.removeEventListener('click', onClick); btn.removeEventListener('keydown', onKey); }); } const copyBtn = ed.querySelector('.copybtn'); if (copyBtn) cleanups.push(attachCopy(copyBtn)); diff --git a/website/src/pages/benchmarks.js b/website/src/pages/benchmarks.js index d99f96ae0..390b050e0 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 ~155 us. +// Rows are same-session comparisons; the raw JDBC single round trip measured ~155-172 us across sessions. const LIBS = { jdbc: {name: 'JDBC', cls: 'jdbc'}, storm: {name: 'Storm', cls: 'storm'}, @@ -123,7 +123,7 @@ function modelLocHtml() { `).join(''); return `
The entity or table definitions by the same counting rule, result DTOs and Storm's optimized write shape excluded. This is the code you write first and read forever.
+The entity or table definitions by the same counting rule, result DTOs and Storm's optimized write shape excluded. This is the model code developers write and maintain.
${rows}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.
${desc}
` : ''} + ${editor({file, tag: 'Kotlin', sql, variants})}`; +} + function buildBody() { const charts = WORKLOADS.map(chartHtml).join('\n'); return ` @@ -932,19 +955,21 @@ ${navHtml('benchmarks')}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.
+Storm was designed around plain entities and queries that closely resemble the SQL they produce. These benchmarks show that the same design also keeps runtime overhead low.
+Seven implementations run against the same PostgreSQL database, using the same schema, data and transaction boundaries. Every result includes a real TCP round trip, and the code behind every number is available to inspect and reproduce.
The workloads cover common data-access paths: point reads, joined entity hydration, projections, batch writes, change-aware updates and one-to-many object graphs.
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()} -On most workloads Storm is the fastest framework; where it isn't, another library edges it narrowly: 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.
+On most workloads Storm is the fastest framework; where it isn't, another library edges it narrowly: 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. Because every result includes a real network round trip, framework overhead represents only part of the reported latency. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.
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.
+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 seven implementations are in the benchmark repository.
${modelLocHtml()} ${locHtml()} +LOC is presented as an illustration of these benchmark implementations, rather than as a universal measure of framework complexity.
-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.
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.
-@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.`,
+ storm: CODE_ENTITIES,
+ others: [
+ {label: 'JDBC', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JDBC, selected: true},
+ {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},
+ ],
+ })}
- 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', 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}, - ]})} + ${codeBlock({ + title: 'Primary key lookup', + file: 'singleRowById', + storm: CODE_SINGLE, + sql: SQL_SINGLE, + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_SINGLE_JDBC, selected: true}, + {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}, + ], + })} -A template picks three columns across the graph; the metamodel keeps every path compile-checked.
- ${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}, - ]})} + ${codeBlock({ + title: 'Three-table join', + file: 'joinWithMapping', + desc: '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.', + storm: CODE_JOIN, + sql: SQL_JOIN, + sqlExtras: [['Exposed DAO', SQL_JOIN_EXPOSED_DAO], ['Jimmer', SQL_JOIN_JIMMER]], + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_JOIN_JDBC, selected: true}, + {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}, + {label: 'Jimmer', tag: 'Java', code: CODE_JOIN_JIMMER}, + ], + })} -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.
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', 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}, - ]})} + ${codeBlock({ + title: 'Read, modify, update', + file: 'updateById', + desc: `Storm's regularOwner 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.`,
+ storm: CODE_UPDATE,
+ sql: SQL_UPDATE,
+ others: [
+ {label: 'JDBC', tag: 'Java', code: CODE_UPDATE_JDBC, selected: true},
+ {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},
+ ],
+ })}
+
+ ${codeBlock({
+ title: 'Object graph',
+ file: 'objectGraph',
+ desc: 'One query, grouped during hydration. Repeated owners deduplicate to the same instance, so grouping is an identity operation, not a hash of every field.',
+ storm: CODE_GRAPH_STORM,
+ sql: SQL_GRAPH,
+ sqlExtras: [['Hibernate', SQL_GRAPH_HIBERNATE], ['jOOQ', SQL_GRAPH_JOOQ], ['Exposed DAO', SQL_GRAPH_EXPOSED_DAO], ['Jimmer', SQL_GRAPH_JIMMER]],
+ others: [
+ {label: 'JDBC', tag: 'Java', code: CODE_GRAPH_JDBC, selected: true},
+ {label: 'Hibernate', tag: 'Java', code: CODE_GRAPH_HIBERNATE},
+ {label: 'jOOQ', tag: 'Java', code: CODE_GRAPH_JOOQ},
+ {label: 'Exposed', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED},
+ {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED_DAO},
+ {label: 'Jimmer', tag: 'Java', code: CODE_GRAPH_JIMMER},
+ ],
+ })}
+
+ 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.
+The suite is built to be argued with. Everything below is enforced in code, not prose.
join fetch and @DynamicUpdate, jOOQ with generated records and MULTISET, Jimmer with fetchers, Exposed in both DSL and DAO flavors.Performance matters most when the programming model also fits your application. Explore the Storm documentation or build the example application yourself.
+ +