diff --git a/website/src/components/tutorial/tutorialTheme.js b/website/src/components/tutorial/tutorialTheme.js index 0b8d19518..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} @@ -136,7 +145,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 @@ -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)); @@ -361,6 +378,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} @@ -387,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 608ea0755..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'; @@ -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]}, }, ]; @@ -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. @@ -418,18 +942,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 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()} -

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.

+

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,44 +964,96 @@ ${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

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