diff --git a/docs/comparison.md b/docs/comparison.md index e6a19ddf6..2e01bdc5f 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -15,6 +15,7 @@ The following tables provide a side-by-side comparison of concrete features acro |---------|-------|-----|-------------|---------|------|------|--------|---------|-------| | Lines per entity | ~5 | ~301 | ~301 | ~20+ | Generated | ~15 | ~10 | ~12 | ~15 | | Immutable entities | Yes | No | No | Yes | Yes | Yes | Yes | DSL only | No | +| Session state | None | Persistence context | Via JPA | None | None | None | None | DAO only | Entity tracking | | Polymorphism | Yes2 | Yes | Via JPA | No | No | No | No8 | No | No | | Automatic relationships | Yes | Yes3 | Via JPA | No | No | No | Yes | DAO only | No | | Cascade persist | No | Yes | Yes | No | No | No | Yes | No | No | @@ -33,8 +34,9 @@ The following tables provide a side-by-side comparison of concrete features acro | Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Jimmer | Exposed | Ktorm | |---------|-------|-----|-------------|---------|------|------|--------|---------|-------| | Type-safe queries | Yes | Criteria | No | No | Yes | No | Yes | Yes | Yes | -| SQL Templates | Yes | No | No | XML/Ann | Yes | Yes | Native9 | No | No | +| SQL / SQL templates | Yes | Native queries | Via JPA | XML/Ann | Yes | Yes | Native9 | exec() | Raw JDBC | | N+1 prevention | Yes | No | No | No | Manual | Manual | Yes | No | No | +| Query across relations | One line | JPQL/Criteria | Derived/JPQL | Manual SQL | Path joins | Manual SQL | Implicit joins | Manual joins | Manual joins | | Lazy loading | Refs | Yes | Yes | No | No | No | Fetchers | Yes | Yes | | Scrolling | Yes | No | Yes | No | Yes | No | No | No | No | | JSON columns | Yes | Yes4 | Via JPA | Manual | Yes | Module | Yes | Yes | Module | diff --git a/website/src/components/tutorial/tutorialTheme.js b/website/src/components/tutorial/tutorialTheme.js index a5c981c0c..57224e52c 100644 --- a/website/src/components/tutorial/tutorialTheme.js +++ b/website/src/components/tutorial/tutorialTheme.js @@ -30,33 +30,92 @@ export const QK = (x) => `${esc(x)}`; export const QQ = (x) => `${esc(x)}`; export const QC = (x) => `${esc(x)}`; +// Supported Kotlin lines for install snippets (newest selected by default), +// shared by every page that renders a build.gradle.kts block. KSP versions are +// Kotlin-paired (-) up to Kotlin 2.2; from Kotlin 2.3 on, KSP +// versions independently and its latest release covers recent Kotlin versions. +// The storm-compiler-plugin suffix tracks the Kotlin major.minor. +export const KOTLIN_VARIANTS = [ + {label: 'Kotlin 2.0', kotlin: '2.0.21', ksp: '2.0.21-1.0.28', plugin: '2.0'}, + {label: 'Kotlin 2.1', kotlin: '2.1.21', ksp: '2.1.21-2.0.2', plugin: '2.1'}, + {label: 'Kotlin 2.2', kotlin: '2.2.21', ksp: '2.2.21-2.0.5', plugin: '2.2'}, + {label: 'Kotlin 2.3', kotlin: '2.3.21', ksp: '2.3.9', plugin: '2.3'}, + {label: 'Kotlin 2.4', kotlin: '2.4.0', ksp: '2.3.9', plugin: '2.4', selected: true}, +]; + +// Database dialects for CREATE TABLE snippets, shared by every page that +// renders a schema block (H2 selected: it matches the zero-setup in-memory +// examples). Identity columns and string/float types differ per engine, and +// MySQL ignores inline REFERENCES so it needs a table-level FOREIGN KEY +// (inlineFk: false). +export const DATABASE_VARIANTS = [ + {label: 'H2', selected: true, idType: 'INT', idClause: 'GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY', strType: 'VARCHAR', strLen: true, intType: 'INT', dblType: 'DOUBLE PRECISION', inlineFk: true}, + {label: 'PostgreSQL', idType: 'INT', idClause: 'GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY', strType: 'VARCHAR', strLen: true, intType: 'INT', dblType: 'DOUBLE PRECISION', inlineFk: true}, + {label: 'MySQL · MariaDB', idType: 'INT', idClause: 'PRIMARY KEY AUTO_INCREMENT', strType: 'VARCHAR', strLen: true, intType: 'INT', dblType: 'DOUBLE', inlineFk: false}, + {label: 'Oracle', idType: 'NUMBER', idClause: 'GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY', strType: 'VARCHAR2', strLen: true, intType: 'NUMBER', dblType: 'BINARY_DOUBLE', inlineFk: true}, + {label: 'SQL Server', idType: 'INT', idClause: 'IDENTITY PRIMARY KEY', strType: 'VARCHAR', strLen: true, intType: 'INT', dblType: 'FLOAT', inlineFk: true}, + {label: 'SQLite', idType: 'INTEGER', idClause: 'PRIMARY KEY', strType: 'TEXT', strLen: false, intType: 'INTEGER', dblType: 'REAL', inlineFk: true}, +]; + // An editor-chrome code block. `code` and `sql` are pre-highlighted HTML built // with the token helpers above. When `sql` is present the block gets a // "Show SQL" toggle that reveals the generated SQL, like the landing editor. -export function editor({file, tag, code, sql}) { +// When `copy` is present the block gets a "Copy" button: pass a plain-text +// string to copy exactly that, or `true` to copy the block's code (derived by +// stripping the highlight markup; with variants, the visible variant's code). +// `variants` ([{label, code, copy?, selected?}]) replaces `code` with several +// switchable snippets and puts a selector in the title bar; every variant is +// pre-rendered as its own pane so switching is a pure display toggle. +export function editor({file, tag, code, sql, copy, variants}) { + // The highlighted HTML is entirely esc()'d text inside spans, so dropping + // the tags and unescaping recovers the source text exactly. + const toPlain = (html) => + html.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); // Newlines only occur in the escaped text tokens, never inside tag markup, // so the line count can be taken from the HTML string directly. - const lines = code.split('\n').length; - let gutter = ''; - for (let i = 1; i <= lines; i++) gutter += `
${i}
`; + const pane = (html) => { + const lines = html.split('\n').length; + let gutter = ''; + for (let i = 1; i <= lines; i++) gutter += `
${i}
`; + return `
${gutter}
+
${html}
`; + }; + const selectedIdx = variants ? Math.max(0, variants.findIndex((v) => v.selected)) : 0; + const varSel = variants + ? `` + : ''; const sqlBtn = sql ? `Show SQL` : ''; + const copyText = + copy === true ? toPlain(variants ? variants[selectedIdx].code : code) : copy; + const copyBtn = copyText + ? `Copy` + : ''; const sqlConsole = sql ? `
generated sql
${sql}
` : ''; + const codeareas = variants + ? variants + .map((v, i) => { + const paneCopy = v.copy || (copy === true ? toPlain(v.code) : null); + return `
${pane(v.code)}
`; + }) + .join('') + : `
${pane(code)}
`; return `
${esc(file)} ${tag ? `${esc(tag)}` : ''} - ${sqlBtn} -
-
-
${gutter}
-
${code}
+ ${varSel}${sqlBtn}${copyBtn}
+ ${codeareas} ${sqlConsole}
`; } @@ -71,23 +130,69 @@ export function glance({left, right}) { `; } -// Wires up every "Show SQL" button rendered by editor(). Returns a cleanup -// function for the calling useEffect. +// Wires up every "Show SQL" and "Copy" button rendered by editor(). Returns a +// cleanup function for the calling useEffect. export function wireSqlToggles() { - const handlers = []; + const cleanups = []; document.querySelectorAll('.storm-tut .editor').forEach((ed) => { const btn = ed.querySelector('.sqlbtn'); - if (!btn) return; - const txt = btn.querySelector('.sqlbtntext'); - const onClick = () => { - const on = ed.classList.toggle('show-sql'); - btn.classList.toggle('on', on); - if (txt) txt.textContent = on ? 'Hide SQL' : 'Show SQL'; - }; - btn.addEventListener('click', onClick); - handlers.push([btn, onClick]); + if (btn) { + const txt = btn.querySelector('.sqlbtntext'); + const onClick = () => { + const on = ed.classList.toggle('show-sql'); + btn.classList.toggle('on', on); + if (txt) txt.textContent = on ? 'Hide SQL' : 'Show SQL'; + }; + btn.addEventListener('click', onClick); + cleanups.push(() => btn.removeEventListener('click', onClick)); + } + const copyBtn = ed.querySelector('.copybtn'); + if (copyBtn) { + const txt = copyBtn.querySelector('.copybtntext'); + let timer = null; + const onCopy = () => { + const text = copyBtn.dataset.copy; + // The Clipboard API needs a secure context; fall back to a throwaway + // textarea so copy also works on plain-http previews (e.g. LAN). + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + copyBtn.classList.add('on'); + if (txt) txt.textContent = 'Copied'; + clearTimeout(timer); + timer = setTimeout(() => { + copyBtn.classList.remove('on'); + if (txt) txt.textContent = 'Copy'; + }, 1600); + }; + copyBtn.addEventListener('click', onCopy); + cleanups.push(() => { + copyBtn.removeEventListener('click', onCopy); + clearTimeout(timer); + }); + } + const sel = ed.querySelector('.varsel'); + if (sel) { + const panes = ed.querySelectorAll('.varpane'); + const onChange = () => { + const idx = Number(sel.value); + panes.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; + }; + sel.addEventListener('change', onChange); + cleanups.push(() => sel.removeEventListener('change', onChange)); + } }); - return () => handlers.forEach(([btn, fn]) => btn.removeEventListener('click', fn)); + return () => cleanups.forEach((fn) => fn()); } // Standard head + shell for a tutorial article page. Keeps the per-page files @@ -143,7 +248,7 @@ export const navHtml = (active) => ` Comparison Blog Docs - GitHub + GitHub Get started `; @@ -151,7 +256,7 @@ export const navHtml = (active) => ` export const FOOT_HTML = ` `; export const TUT_CSS = ` @@ -209,6 +314,10 @@ export const TUT_CSS = ` .storm-tut p code,.storm-tut li code,.storm-tut td code{font-family:var(--mono);font-size:.86em;background:var(--panel);border:1px solid var(--border-soft);border-radius:6px;padding:1.5px 6px;color:var(--plain);white-space:nowrap} .storm-tut .art a.tlink{color:var(--accent)} .storm-tut .art a.tlink:hover{text-decoration:underline} + /* Star ask (combined with .grad): the gradient text is transparent, so the + underline color is set explicitly and only revealed on hover. */ + .storm-tut .art a.star{font-weight:600;text-decoration:none} + .storm-tut .art a.star:hover{text-decoration:underline;text-decoration-color:#a5b4fc;text-decoration-thickness:2px;text-underline-offset:4px} /* editor chrome (landing look, sized for articles) */ .storm-tut .editor{margin:26px 0 0;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--panel); @@ -217,11 +326,21 @@ export const TUT_CSS = ` .storm-tut .dot{width:11px;height:11px;border-radius:50%;flex:none}.storm-tut .dot.r{background:#ff5f57}.storm-tut .dot.y{background:#febc2e}.storm-tut .dot.g{background:#28c840} .storm-tut .fname{margin-left:10px;font-family:var(--mono);font-size:12.5px;color:var(--faint)} .storm-tut .langtag{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--faint);letter-spacing:.05em} - .storm-tut .sqlbtn{margin-left:14px;display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11.5px; + .storm-tut .sqlbtn,.storm-tut .copybtn{margin-left:14px;display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11.5px; color:var(--accent);border:1px solid rgba(129,140,248,.3);border-radius:7px;padding:4px 10px;cursor:pointer;transition:.16s;user-select:none} - .storm-tut .sqlbtn:hover{background:rgba(129,140,248,.12);border-color:rgba(129,140,248,.5)} - .storm-tut .sqlbtn.on{background:rgba(129,140,248,.16);color:#aab2ff} - .storm-tut .sqlbtn .ico{width:13px;height:13px;opacity:.9} + .storm-tut .sqlbtn:hover,.storm-tut .copybtn:hover{background:rgba(129,140,248,.12);border-color:rgba(129,140,248,.5)} + .storm-tut .sqlbtn.on,.storm-tut .copybtn.on{background:rgba(129,140,248,.16);color:#aab2ff} + .storm-tut .sqlbtn .ico,.storm-tut .copybtn .ico{width:13px;height:13px;opacity:.9} + /* 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} + .storm-tut .varwrap::after{content:"▾";position:absolute;right:9px;pointer-events:none;color:var(--accent);font-size:10px} + .storm-tut .varsel{appearance:none;-webkit-appearance:none;font-family:var(--mono);font-size:11.5px;color:var(--accent); + background:transparent;border:1px solid rgba(129,140,248,.3);border-radius:7px;padding:4px 24px 4px 10px;cursor:pointer;transition:.16s} + .storm-tut .varsel:hover{background:rgba(129,140,248,.12);border-color:rgba(129,140,248,.5)} + .storm-tut .varsel option{background:var(--panel-2);color:var(--text)} + .storm-tut .codearea.varpane{display:none} + .storm-tut .codearea.varpane.on{display:flex} .storm-tut .codearea{display:flex;background:linear-gradient(180deg,var(--panel),var(--panel-2))} .storm-tut .gutter{padding:18px 0;width:46px;text-align:right;color:#3b3b46;font-family:var(--mono);font-size:12px; line-height:24px;user-select:none;border-right:1px solid var(--border-soft);flex:none} @@ -243,6 +362,9 @@ export const TUT_CSS = ` /* at-a-glance two-pane strip */ .storm-tut .glance{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:26px 0 0} @media(max-width:720px){.storm-tut .glance{grid-template-columns:1fr}} + /* On phones the title bar can't fit filename + language tag + selector, so + the language tag yields when a variant selector is present. */ + @media(max-width:600px){.storm-tut .ebar:has(.varsel) .langtag{display:none}} .storm-tut .gpane{border:1px solid var(--border-soft);border-radius:12px;background:var(--panel-2);overflow:hidden} .storm-tut .gpane .glabel{display:flex;align-items:center;height:34px;padding:0 14px;border-bottom:1px solid var(--border-soft); font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)} @@ -268,14 +390,20 @@ export const TUT_CSS = ` .storm-tut .cmp tbody tr:first-child td{border-top:0} .storm-tut .cmp tbody tr:nth-child(even) td{background:rgba(255,255,255,.014)} .storm-tut .cmp td:first-child{color:var(--text);font-weight:600} - .storm-tut .cmp thead th:nth-child(2){color:var(--accent-2)} - .storm-tut .cmp tbody tr td:nth-child(2){background:rgba(129,140,248,.07);color:var(--text)} + /* The Storm column carries the brand gradient: clipped into the header text + (like .grad) and washed at low alpha across the cells, so the column reads + as one highlighted band. */ + .storm-tut .cmp thead th:nth-child(2){background:linear-gradient(100deg,#a78bfa,#818cf8 50%,#7dd3fc);-webkit-background-clip:text;background-clip:text;color:transparent} + .storm-tut .cmp tbody tr td:nth-child(2){background:linear-gradient(100deg,rgba(129,140,248,.30),rgba(96,165,250,.26) 50%,rgba(125,211,252,.34));color:var(--text);font-weight:500} /* let long inline code wrap inside cells instead of forcing the table wider than its container (which the rounded overflow:hidden would then clip) */ .storm-tut .cmp th code,.storm-tut .cmp td code{white-space:normal;word-break:break-word} /* cta + doc refs */ - .storm-tut .cta{display:flex;gap:14px;margin-top:36px;flex-wrap:wrap} + .storm-tut .cta{display:flex;gap:14px;margin-top:36px;flex-wrap:wrap;align-items:center} + /* Star ask sharing the closing CTA row: centered against the button height, + wraps below it on narrow screens. */ + .storm-tut .cta .starline{margin:0;color:var(--muted);font-size:15px} .storm-tut .refs{display:flex;gap:8px;margin-top:18px;flex-wrap:wrap} .storm-tut .refs a{font-family:var(--mono);font-size:12px;color:var(--muted);border:1px solid var(--border-soft);border-radius:999px;padding:6px 14px;transition:.16s} .storm-tut .refs a:hover{color:var(--accent);border-color:rgba(129,140,248,.4)} diff --git a/website/src/pages/comparison.js b/website/src/pages/comparison.js index 35d1f75d4..92ffa2503 100644 --- a/website/src/pages/comparison.js +++ b/website/src/pages/comparison.js @@ -5,10 +5,12 @@ import {TUT_CSS, navHtml, FOOT_HTML, wireSqlToggles} from '../components/tutoria // The comparison page at /comparison, in the landing/tutorial style. A // scannable, code-light companion to the in-depth docs/comparison.md: a // decision matrix plus a compact card per framework, each linking to the full -// docs pairing. Jimmer is treated exactly like the other frameworks (a matrix -// column and a card), with its detailed pairing living in the docs alongside -// the rest. Kept fair per the project's competitor-copy rule: only differences -// that are real and that the rival does not already cover are called out. +// docs pairing. Jimmer has a card but no matrix column: the matrix stays at +// the mainstream decision set for readability, while the card and its docs +// pairing cover the closest peer in the place that can express the real +// difference (conciseness). Kept fair per the project's competitor-copy rule: +// only differences that are real and that the rival does not already cover +// are called out. const TITLE = 'Comparison · Storm vs JPA, jOOQ, Exposed, Jimmer'; const DESC = @@ -66,16 +68,21 @@ function buildBody() { const matrix = ` - + - - - - - - - + + + + + + + + + + + +
FeatureStormJPA / HibernatejOOQJimmerExposedKtormFeatureStormJPA / HibernatejOOQExposedKtorm
Entity modelImmutable data class (~5 lines)Mutable class (~30, ~10 with Lombok)Generated from schemaImmutable interface (KSP-generated)DSL table object (+ optional DAO)Mutable interface (+ DSL table)
Immutable entitiesYesNoYesYesDSL onlyNo
Type-safe queriesYesCriteria APIYesYesYesYes
N+1 handlingSingle-query entity graphCommon pitfallManualBatched queriesManualManual
Full SQL escape hatchSQL templatesNative queriesIt is SQLSQL expressionsRaw exec()Raw JDBC
LanguagesKotlin + JavaKotlin + JavaKotlin + JavaKotlin + JavaKotlin onlyKotlin only
LicenseApache 2.0LGPL 2.1Commercial for some DBsApache 2.0Apache 2.0Apache 2.0
Entity modelImmutable data class (~5 lines)Mutable class (~30, ~10 with Lombok)Generated from schemaDSL table object (+ optional DAO)Mutable interface (+ DSL table)
Immutable entitiesYesNoYesDSL onlyNo
Type-safe queriesYesCriteria APIYesYesYes
N+1 handlingSingle-query entity graphCommon pitfallManualManualManual
Query across relationsOne line: joins and mapping derived from @FKJPQL strings or Criteria buildersImplicit path joins + multisetManual joins and row mappingManual joins
Session stateNone: no session, no flushPersistence context, flush, dirty checkingNoneDSL none · DAO transaction-boundPer-entity change tracking
Deferred loadingExplicit Ref<T>Proxies, session-boundNot applicableDAO lazy, transaction-boundManual joins
Standalone transactionsPropagation, isolation, timeout, commit hooksEntityTransaction · JTA/Spring for moreLambda API, nestedNested, isolation configLambda API
Row mappingCompile-time generated, reflection-freeReflection / bytecodeGenerated recordsManual (DSL)Dynamic proxies
Full SQL escape hatchSQL / SQL templatesNative queriesIt is SQLRaw exec()Raw JDBC
LanguagesKotlin + JavaKotlin + JavaKotlin + JavaKotlin onlyKotlin only
LicenseApache 2.0LGPL 2.1Commercial for some DBsApache 2.0Apache 2.0
`; diff --git a/website/src/pages/index.js b/website/src/pages/index.js index a2872892b..5e53d872c 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -39,6 +39,12 @@ const CSS = ` .storm-home .btn:hover{border-color:#34343d;transform:translateY(-1px)} .storm-home .btn.primary{background:var(--accent);color:#0a0a0f;border-color:var(--accent);font-weight:600} .storm-home .btn.primary:hover{background:#9aa3ff} + /* The hero conversion button is filled with the brand gradient (the same one + .grad clips into the hero text), so it pops by brightness while staying + perfectly on-palette. Border off: it can't take a gradient and the fill + supplies the edge. */ + .storm-home .btn.primary.go{background:linear-gradient(100deg,#a78bfa,#818cf8 50%,#7dd3fc);border-color:transparent} + .storm-home .btn.primary.go:hover{filter:brightness(1.12)} /* hero — left aligned */ .storm-home header{padding:46px 0 34px} @@ -81,15 +87,24 @@ const CSS = ` @media(prefers-reduced-motion:reduce){.storm-home .hero-rot>span,.storm-home .hero-swap>span{transition:none}} /* On small screens the h1 min (42px) is too large for the longer principles to stay on one line, so scale the rotating line down a little below the hero. */ - @media(max-width:600px){.storm-home .hero-rot>span{font-size:clamp(20px,6vw,30px)}} + @media(max-width:600px){ + .storm-home .hero-rot>span{font-size:clamp(20px,6vw,30px)} + /* Tighten the fold: campaign traffic is ~100% mobile and the hero CTA must + be tappable without scrolling, so shrink the header spacing and sub copy + and stack the buttons full-width. */ + .storm-home header{padding:30px 0 26px} + .storm-home .sub{font-size:15.5px;margin-top:18px} + .storm-home .hero-cta{margin-top:20px} + .storm-home .hero-cta .btn{flex:1 1 100%;justify-content:center;height:44px} + .storm-home .stage{margin-top:34px} + } .storm-home .sub{max-width:600px;margin:24px 0 0;color:var(--muted);font-size:18px;line-height:1.62} .storm-home .sub a{color:var(--accent);text-decoration:underline;text-underline-offset:3px} .storm-home .sub a:hover{color:#9aa3ff} - /* "Your move." keeps the hero gradient (transparent color, so the generic - .sub a accent must not win) and only reveals its underline on hover. */ - .storm-home .sub a.hero-move{font-weight:600;color:transparent;text-decoration:none} - .storm-home .sub a.hero-move:hover{text-decoration:underline;text-decoration-color:#a5b4fc;text-decoration-thickness:2px;text-underline-offset:4px} .storm-home .cta{display:flex;gap:14px;margin-top:32px;flex-wrap:wrap} + /* Hero CTA: the primary conversion action, kept high so it sits above the + fold on phones. */ + .storm-home .hero-cta{margin-top:26px} /* editor */ .storm-home .stage{margin:54px 0 0;max-width:880px} @@ -214,7 +229,7 @@ const BODY = ` Comparison Blog Docs - GitHub + GitHub Get started @@ -228,14 +243,17 @@ const BODY = ` Try it. Break it. Challenge it. - Love it. - Hate it. - Tell us. - Follow us. - Join us. + Love it. + Hate it. + Tell us. + Follow us. + Join us.

How would you design an ORM you would enjoy using? Immutable data-class entities? One-line queries, checked at compile time? No proxies, no N+1, no persistence context? That is ST/ORM.

-

After 18 months of commercial use, it is ready to be challenged. Your move.

+
@@ -304,13 +322,13 @@ const BODY = `
`; diff --git a/website/src/pages/quickstart.js b/website/src/pages/quickstart.js index 1ba4807a1..c2e1ee11c 100644 --- a/website/src/pages/quickstart.js +++ b/website/src/pages/quickstart.js @@ -7,30 +7,36 @@ import { FOOT_HTML, editor, wireSqlToggles, + KOTLIN_VARIANTS, + DATABASE_VARIANTS, K, T, S, C, F, N, A, P, QK, QQ, QC, } from '../components/tutorial/tutorialTheme'; // The 5-minute quickstart. Built in the landing/tutorial style (see // tutorialTheme.js) rather than as a docs page so it deploys live immediately // and is the target of every "Get started" button on the site. Kotlin-first; -// leads with the Storm CLI one-liner (AI-assisted setup), with the manual build -// right below. The install snippet renders the resolved release version, read -// from siteConfig.customFields.stormVersion (docusaurus.config.ts). +// leads with the manual build, with the Storm CLI one-liner (AI-assisted +// setup) as a secondary suggestion below it. The install snippet renders the +// resolved release version, read from siteConfig.customFields.stormVersion +// (docusaurus.config.ts). -const TITLE = 'Quickstart · Your first Storm query in five minutes'; +const TITLE = 'Quickstart · Zero to Storm in five minutes'; const DESC = - 'Set up Storm, define an entity as a plain Kotlin data class, and run a ' + - 'type-safe query. The whole path from empty project to first query, with the ' + - 'SQL it generates.'; + 'Set up Storm, define two linked entities as plain Kotlin data classes, and ' + + 'query across the relation in one type-safe line. The whole path from empty ' + + 'project to first query, with the SQL it generates.'; function buildBody(version) { - const cli = P('npx @storm-orm/cli init'); + const cliCommand = 'npx @storm-orm/cli init'; + const cli = C("# from the root of your project's workspace\n") + P(cliCommand); - const install = + // One install snippet per supported Kotlin line (KOTLIN_VARIANTS in the + // shared theme), switchable in the editor title bar. + const installFor = ({kotlin, ksp, plugin}) => C('// build.gradle.kts\n') + F('plugins') + P(' {\n') + - P(' ') + F('kotlin') + P('(') + S('"jvm"') + P(') version ') + S('"2.0.21"') + P('\n') + - P(' ') + F('id') + P('(') + S('"com.google.devtools.ksp"') + P(') version ') + S('"2.0.21-1.0.28"') + P('\n') + + P(' ') + F('kotlin') + P('(') + S('"jvm"') + P(') version ') + S(`"${kotlin}"`) + P('\n') + + P(' ') + F('id') + P('(') + S('"com.google.devtools.ksp"') + P(') version ') + S(`"${ksp}"`) + P('\n') + P('}\n\n') + F('dependencies') + P(' {\n') + P(' ') + F('implementation') + P('(') + F('platform') + P('(') + S(`"st.orm:storm-bom:${version}"`) + P('))\n') + @@ -39,70 +45,112 @@ function buildBody(version) { P(' ') + F('runtimeOnly') + P('(') + S('"st.orm:storm-h2"') + P(') ') + C('// zero-setup in-memory database\n') + P(' ') + F('runtimeOnly') + P('(') + S('"com.h2database:h2:2.2.224"') + P(')\n') + P(' ') + F('ksp') + P('(') + S('"st.orm:storm-metamodel-ksp"') + P(')\n') + - P(' ') + F('kotlinCompilerPluginClasspath') + P('(') + S('"st.orm:storm-compiler-plugin-2.0"') + P(')\n') + + P(' ') + F('kotlinCompilerPluginClasspath') + P('(') + S(`"st.orm:storm-compiler-plugin-${plugin}"`) + P(')\n') + P('}'); const entity = - C('// Movie.kt — a plain data class. This is the whole entity.\n') + + C('// Entities.kt — plain data classes. This is the whole data layer.\n') + + K('data class ') + T('Director') + P('(\n') + + P(' ') + A('@PK') + P(' ') + K('val ') + P('id: ') + T('Int') + P(' = ') + N('0') + P(',\n') + + P(' ') + K('val ') + P('name: ') + T('String') + P(',\n') + + P(') : ') + T('Entity') + P('<') + T('Int') + P('>\n\n') + + C('// A relation is a @FK field typed as the entity it points to.\n') + K('data class ') + T('Movie') + P('(\n') + P(' ') + A('@PK') + P(' ') + K('val ') + P('id: ') + T('Int') + P(' = ') + N('0') + P(',\n') + P(' ') + K('val ') + P('title: ') + T('String') + P(',\n') + P(' ') + K('val ') + P('year: ') + T('Int') + P(',\n') + P(' ') + K('val ') + P('rating: ') + T('Double') + P(',\n') + + P(' ') + A('@FK') + P(' ') + K('val ') + P('director: ') + T('Director') + P(',\n') + P(') : ') + T('Entity') + P('<') + T('Int') + P('>'); - const schema = - C('-- create the table with your migration tool, or run this once\n') + - K('CREATE TABLE ') + P('movie (\n') + - P(' id ') + T('INT') + P(' ') + K('PRIMARY KEY AUTO_INCREMENT') + P(',\n') + - P(' title ') + T('VARCHAR') + P('(') + N('200') + P('),\n') + - P(' year ') + T('INT') + P(',\n') + - P(' rating ') + T('DOUBLE') + P('\n') + - P(');'); + // One DDL script per supported database (DATABASE_VARIANTS in the shared + // theme), switchable in the editor title bar. + const schemaFor = ({idType, idClause, strType, strLen, intType, dblType, inlineFk}) => { + const strCol = (n) => + strLen ? T(strType) + P('(') + N(String(n)) + P(')') : T(strType); + const fkLines = inlineFk + ? P(' director_id ') + T(idType) + P(' ') + K('REFERENCES') + P(' director(id)\n') + : P(' director_id ') + T(idType) + P(',\n') + + P(' ') + K('FOREIGN KEY') + P(' (director_id) ') + K('REFERENCES') + P(' director(id)\n'); + return ( + C('-- create the tables with your migration tool, or run this once\n') + + K('CREATE TABLE ') + P('director (\n') + + P(' id ') + T(idType) + P(' ') + K(idClause) + P(',\n') + + P(' name ') + strCol(100) + P('\n') + + P(');\n\n') + + K('CREATE TABLE ') + P('movie (\n') + + P(' id ') + T(idType) + P(' ') + K(idClause) + P(',\n') + + P(' title ') + strCol(200) + P(',\n') + + P(' year ') + T(intType) + P(',\n') + + P(' rating ') + T(dblType) + P(',\n') + + fkLines + + P(');') + ); + }; + const query = C('// Open an ORM on any JDBC DataSource. Thread-safe; create it once.\n') + K('val ') + P('orm = dataSource.orm\n\n') + + C('// Insert returns a copy with the generated id.\n') + + K('val ') + P('nolan = orm ') + K('insert ') + T('Director') + P('(name = ') + S('"Christopher Nolan"') + P(')\n') + + K('val ') + P('villeneuve = orm ') + K('insert ') + T('Director') + P('(name = ') + S('"Denis Villeneuve"') + P(')\n\n') + + P('orm ') + K('insert ') + T('Movie') + P('(title = ') + S('"Interstellar"') + P(', year = ') + N('2014') + P(', rating = ') + N('8.7') + P(', director = nolan)\n') + + P('orm ') + K('insert ') + T('Movie') + P('(title = ') + S('"Oppenheimer"') + P(', year = ') + N('2023') + P(', rating = ') + N('8.3') + P(', director = nolan)\n') + + P('orm ') + K('insert ') + T('Movie') + P('(title = ') + S('"Dune: Part Two"') + P(', year = ') + N('2024') + P(', rating = ') + N('8.5') + P(', director = villeneuve)\n\n') + C('// A repository for Movie: every CRUD method included, nothing to implement.\n') + K('val ') + P('movies = orm.') + F('entity') + P('<') + T('Movie') + P('>()\n\n') + - C('// Insert returns a copy with the generated id.\n') + - K('val ') + P('saved = orm ') + K('insert ') + T('Movie') + P('(title = ') + S('"Dune: Part Two"') + P(', year = ') + N('2024') + P(', rating = ') + N('8.5') + P(')\n\n') + - C('// One type-safe line. Movie_ is generated at compile time, so a typo fails to compile.\n') + - K('val ') + P('recent = movies.') + F('findAll') + P('(Movie_.year ') + K('eq') + P(' ') + N('2024') + P(')'); + C('// One type-safe line across the relation. Movie_ is generated at compile time,\n') + + C('// so a typo in the path fails to compile.\n') + + K('val ') + P('byChris = movies.') + F('findAll') + P('(Movie_.director.name ') + K('like') + P(' ') + S('"Chris%"') + P(')\n\n') + + C('// The director is loaded in the same query: no proxies, no N+1.\n') + + K('val ') + P('director = byChris.first().director.name'); const querySql = QC('-- orm insert Movie(...)\n') + - QK('INSERT INTO') + ' movie (title, year, rating) ' + QK('VALUES') + ' (' + QQ('?') + ', ' + QQ('?') + ', ' + QQ('?') + ')\n\n' + - QC('-- movies.findAll(Movie_.year eq 2024)\n') + - QK('SELECT') + ' m.id, m.title, m.year, m.rating\n' + + QK('INSERT INTO') + ' movie (title, year, rating, director_id) ' + QK('VALUES') + ' (' + QQ('?') + ', ' + QQ('?') + ', ' + QQ('?') + ', ' + QQ('?') + ')\n\n' + + QC('-- movies.findAll(Movie_.director.name like "Chris%")\n') + + QC('-- one statement, join included: the whole graph, no N+1\n') + + QK('SELECT') + ' m.id, m.title, m.year, m.rating, d.id, d.name\n' + QK('FROM') + ' movie m\n' + - QK('WHERE') + ' m.year = ' + QQ('?'); + QK('INNER JOIN') + ' director d ' + QK('ON') + ' m.director_id = d.id\n' + + QK('WHERE') + ' d.name ' + QK('LIKE') + ' ' + QQ('?'); return ` ${navHtml('')}
Home/Quickstart
-

Your first query.
Five minutes.

-

Install Storm, define an entity as a plain Kotlin data class, and run a type-safe query. No persistence context, no proxies, no XML. Here is the whole path.

+

Zero to Storm
in 5 minutes.

+

Install Storm, define two linked entities as plain data classes, and query across the relation in one type-safe line. No persistence context, no proxies, no XML. Here is the whole path.

Kotlin~5 minJDK 21+

1Set up

-

The fastest way in is the Storm CLI. It installs Storm-aware rules and skills for your AI coding assistant (Claude, Cursor, Copilot, Windsurf, Codex) and sets up a schema-aware MCP server, so the entities and queries it generates match your real schema.

- ${editor({file: 'terminal', tag: 'shell', code: cli})} -

Prefer to wire the build by hand? Add the Storm modules yourself. This is the full Kotlin set for a runnable project on an in-memory H2 database; the BOM keeps the versions aligned.

- ${editor({file: 'build.gradle.kts', tag: 'Gradle · Kotlin DSL', code: install})} +

Add the Storm modules to your build. This is the full Kotlin set for a runnable project on an in-memory H2 database; the BOM keeps the versions aligned.

+ ${editor({ + file: 'build.gradle.kts', + tag: 'Gradle · Kotlin DSL', + copy: true, + variants: KOTLIN_VARIANTS.map((v) => ({label: v.label, code: installFor(v), selected: v.selected})), + })}

The ksp dependency generates the type-safe metamodel (Movie_), and the compiler plugin makes SQL templates injection-safe by default. On a real database, swap storm-h2 for your dialect and add its JDBC driver. See the installation guide for all options.

- -

2Define an entity

-

Entities are plain immutable data classes that implement Entity<ID>. Field names map to columns automatically (camelCase to snake_case). There is no base class to extend and no generated draft type to route through: the class you write is the object you get back.

- ${editor({file: 'Movie.kt', tag: 'Kotlin', code: entity})} -

Storm maps to an existing schema rather than creating one, so define the table with your migration tool (Flyway, Liquibase) or a one-off DDL script. Storm can verify at startup that your entities match it with validateSchema().

- ${editor({file: 'schema.sql', tag: 'SQL', code: schema})} - -

3Run a query

-

Open an ORMTemplate on your DataSource, ask for a repository, and query it. Toggle Show SQL to see exactly what Storm runs: one statement, fully parameterized, no surprises.

- ${editor({file: 'Main.kt', tag: 'Kotlin', code: query, sql: querySql})} +

Working with an AI coding assistant? One command, run from the root of your project's workspace, installs Storm-aware rules and skills for it (Claude, Cursor, Copilot, Windsurf, Codex) and sets up a schema-aware MCP server, so the entities and queries it generates match your real schema.

+ ${editor({file: 'terminal', tag: 'shell', code: cli, copy: cliCommand})} + +

2Define two linked entities

+

Entities are plain immutable data classes that implement Entity<ID>. Field names map to columns automatically (camelCase to snake_case), and a relation is just a @FK field typed as the entity it points to. There is no base class to extend and no generated draft type to route through: the classes you write are the objects you get back.

+ ${editor({file: 'Entities.kt', tag: 'Kotlin', code: entity, copy: true})} +

Storm maps to an existing schema rather than creating one, so define the tables with your migration tool (Flyway, Liquibase) or a one-off DDL script; pick your database in the block below. Storm can verify at startup that your entities match it with validateSchema().

+ ${editor({ + file: 'schema.sql', + tag: 'SQL', + copy: true, + variants: DATABASE_VARIANTS.map((d) => ({label: d.label, code: schemaFor(d), selected: d.selected})), + })} + +

3Query across the relation

+

Open an ORMTemplate on your DataSource, insert a few records, and filter movies by their director's name in one type-safe line. Toggle Show SQL to see exactly what Storm runs: a single statement with the join included, fully parameterized, no N+1.

+ ${editor({file: 'Main.kt', tag: 'Kotlin', code: query, sql: querySql, copy: true})}

That is the core loop. Insert, findById, update, and remove all come for free on the repository; add your own one-line queries whenever you need them.

4Where to next

@@ -117,7 +165,7 @@ ${navHtml('')}
Build a real app → - Read the docs +

Five minutes well spent? Give us a Star on GitHub.

diff --git a/website/src/pages/tutorials/auditing.js b/website/src/pages/tutorials/auditing.js index 4c8ea23ea..eaef371c5 100644 --- a/website/src/pages/tutorials/auditing.js +++ b/website/src/pages/tutorials/auditing.js @@ -90,7 +90,7 @@ ${navHtml('tutorials')}
diff --git a/website/src/pages/tutorials/build-a-rest-api.js b/website/src/pages/tutorials/build-a-rest-api.js index b1a7264d1..ddb99ce45 100644 --- a/website/src/pages/tutorials/build-a-rest-api.js +++ b/website/src/pages/tutorials/build-a-rest-api.js @@ -5,6 +5,8 @@ import { navHtml, FOOT_HTML, editor, + KOTLIN_VARIANTS, + DATABASE_VARIANTS, K, T, S, C, F, N, A, P, QK, QC, } from '../../components/tutorial/tutorialTheme'; @@ -21,12 +23,12 @@ const DESC = 'routes, and assert the SQL with a test.'; function buildBody(version) { - const gradle = + const gradleFor = ({kotlin, ksp, plugin}) => C('// build.gradle.kts\n') + F('plugins') + P(' {\n') + - P(' ') + F('kotlin') + P('(') + S('"jvm"') + P(') version ') + S('"2.0.21"') + P('\n') + + P(' ') + F('kotlin') + P('(') + S('"jvm"') + P(') version ') + S(`"${kotlin}"`) + P('\n') + P(' ') + F('id') + P('(') + S('"io.ktor.plugin"') + P(') version ') + S('"3.0.3"') + P('\n') + - P(' ') + F('id') + P('(') + S('"com.google.devtools.ksp"') + P(') version ') + S('"2.0.21-1.0.28"') + P('\n') + + P(' ') + F('id') + P('(') + S('"com.google.devtools.ksp"') + P(') version ') + S(`"${ksp}"`) + P('\n') + P('}\n\n') + F('dependencies') + P(' {\n') + P(' ') + F('implementation') + P('(') + F('platform') + P('(') + S(`"st.orm:storm-bom:${version}"`) + P('))\n') + @@ -36,7 +38,7 @@ function buildBody(version) { P(' ') + F('runtimeOnly') + P('(') + S('"st.orm:storm-h2"') + P(')\n') + P(' ') + F('runtimeOnly') + P('(') + S('"com.h2database:h2:2.2.224"') + P(')\n') + P(' ') + F('ksp') + P('(') + S('"st.orm:storm-metamodel-ksp"') + P(')\n') + - P(' ') + F('kotlinCompilerPluginClasspath') + P('(') + S('"st.orm:storm-compiler-plugin-2.0"') + P(')\n\n') + + P(' ') + F('kotlinCompilerPluginClasspath') + P('(') + S(`"st.orm:storm-compiler-plugin-${plugin}"`) + P(')\n\n') + P(' ') + F('implementation') + P('(') + S('"io.ktor:ktor-server-netty"') + P(')\n') + P(' ') + F('implementation') + P('(') + S('"io.ktor:ktor-server-content-negotiation"') + P(')\n') + P(' ') + F('implementation') + P('(') + S('"io.ktor:ktor-serialization-jackson"') + P(')\n') + @@ -45,20 +47,31 @@ function buildBody(version) { P(' ') + F('testImplementation') + P('(') + S('"com.h2database:h2"') + P(')\n') + P('}'); - const schema = - C('-- src/main/resources/schema.sql\n') + - K('CREATE TABLE ') + P('folder (\n') + - P(' id ') + T('INT') + P(' ') + K('PRIMARY KEY AUTO_INCREMENT') + P(',\n') + - P(' name ') + T('VARCHAR') + P('(') + N('100') + P(') ') + K('NOT NULL') + P('\n') + - P(');\n') + - K('CREATE TABLE ') + P('bookmark (\n') + - P(' id ') + T('INT') + P(' ') + K('PRIMARY KEY AUTO_INCREMENT') + P(',\n') + - P(' url ') + T('VARCHAR') + P('(') + N('2000') + P(') ') + K('NOT NULL') + P(',\n') + - P(' title ') + T('VARCHAR') + P('(') + N('200') + P(') ') + K('NOT NULL') + P(',\n') + - P(' folder_id ') + T('INT') + P(' ') + K('NOT NULL REFERENCES') + P(' folder(id)\n') + - P(');\n') + - C('-- one folder to start with\n') + - K('INSERT INTO ') + P('folder (name) ') + K('VALUES') + P(' (') + S("'Reading'") + P(');'); + // One DDL script per supported database (DATABASE_VARIANTS in the shared + // theme). The tutorial itself runs on H2, the default. + const schemaFor = ({idType, idClause, strType, strLen, inlineFk}) => { + const strCol = (n) => + strLen ? T(strType) + P('(') + N(String(n)) + P(')') : T(strType); + const fkLines = inlineFk + ? P(' folder_id ') + T(idType) + P(' ') + K('NOT NULL REFERENCES') + P(' folder(id)\n') + : P(' folder_id ') + T(idType) + P(' ') + K('NOT NULL') + P(',\n') + + P(' ') + K('FOREIGN KEY') + P(' (folder_id) ') + K('REFERENCES') + P(' folder(id)\n'); + return ( + C('-- src/main/resources/schema.sql\n') + + K('CREATE TABLE ') + P('folder (\n') + + P(' id ') + T(idType) + P(' ') + K(idClause) + P(',\n') + + P(' name ') + strCol(100) + P(' ') + K('NOT NULL') + P('\n') + + P(');\n') + + K('CREATE TABLE ') + P('bookmark (\n') + + P(' id ') + T(idType) + P(' ') + K(idClause) + P(',\n') + + P(' url ') + strCol(2000) + P(' ') + K('NOT NULL') + P(',\n') + + P(' title ') + strCol(200) + P(' ') + K('NOT NULL') + P(',\n') + + fkLines + + P(');\n') + + C('-- one folder to start with\n') + + K('INSERT INTO ') + P('folder (name) ') + K('VALUES') + P(' (') + S("'Reading'") + P(');') + ); + }; const entities = C('// Folder.kt\n') + @@ -86,11 +99,13 @@ function buildBody(version) { P('}'); const repo = - C('// BookmarkRepository.kt — CRUD is inherited; add only your own queries\n') + + C('// Repositories.kt — CRUD is inherited; add only your own queries\n') + K('interface ') + T('BookmarkRepository') + P(' : ') + T('EntityRepository') + P('<') + T('Bookmark') + P(', ') + T('Int') + P('> {\n') + P(' ') + K('fun ') + F('findByFolderName') + P('(name: ') + T('String') + P('): ') + T('List') + P('<') + T('Bookmark') + P('> =\n') + P(' ') + F('findAll') + P('(Bookmark_.folder.name ') + K('eq') + P(' name)\n') + - P('}'); + P('}\n\n') + + C('// the minimal case (optional): one line, nothing to implement\n') + + K('interface ') + T('FolderRepository') + P(' : ') + T('EntityRepository') + P('<') + T('Folder') + P(', ') + T('Int') + P('>'); const app = C('// Application.kt\n') + @@ -115,21 +130,22 @@ function buildBody(version) { P(' }\n\n') + P(' ') + F('get') + P('(') + S('"/bookmarks/{id}"') + P(') {\n') + P(' ') + K('val ') + P('id = call.parameters.') + F('getOrFail') + P('(') + S('"id"') + P(').') + F('toInt') + P('()\n') + - P(' ') + K('val ') + P('bookmark = call.orm.') + F('entity') + P('<') + T('Bookmark') + P('>().') + F('findById') + P('(id)\n') + + P(' ') + K('val ') + P('bookmark = ') + F('repository') + P('<') + T('BookmarkRepository') + P('>().') + F('findById') + P('(id)\n') + P(' call.') + F('respond') + P('(bookmark ?: ') + T('HttpStatusCode') + P('.NotFound)\n') + P(' }\n\n') + P(' ') + F('post') + P('(') + S('"/bookmarks"') + P(') {\n') + P(' ') + K('val ') + P('body = call.') + F('receive') + P('<') + T('NewBookmark') + P('>()\n') + - P(' ') + K('val ') + P('folder = call.orm.') + F('entity') + P('<') + T('Folder') + P('>().') + F('findById') + P('(body.folderId)\n') + + P(' ') + K('val ') + P('folder = ') + F('repository') + P('<') + T('FolderRepository') + P('>().') + F('findById') + P('(body.folderId)\n') + P(' ') + K('if ') + P('(folder == ') + K('null') + P(') { call.') + F('respond') + P('(') + T('HttpStatusCode') + P('.BadRequest, ') + S('"unknown folder"') + P('); ') + K('return@post') + P(' }\n') + P(' ') + K('val ') + P('created = ') + F('transaction') + P(' {\n') + - P(' call.orm ') + K('insert ') + T('Bookmark') + P('(url = body.url, title = body.title, folder = folder)\n') + + P(' ') + F('repository') + P('<') + T('BookmarkRepository') + P('>()\n') + + P(' .') + F('insertAndFetch') + P('(') + T('Bookmark') + P('(url = body.url, title = body.title, folder = folder))\n') + P(' }\n') + P(' call.') + F('respond') + P('(') + T('HttpStatusCode') + P('.Created, created)\n') + P(' }\n\n') + P(' ') + F('delete') + P('(') + S('"/bookmarks/{id}"') + P(') {\n') + P(' ') + K('val ') + P('id = call.parameters.') + F('getOrFail') + P('(') + S('"id"') + P(').') + F('toInt') + P('()\n') + - P(' ') + F('transaction') + P(' { call.orm.') + F('entity') + P('<') + T('Bookmark') + P('>().') + F('removeById') + P('(id) }\n') + + P(' ') + F('transaction') + P(' { ') + F('repository') + P('<') + T('BookmarkRepository') + P('>().') + F('removeById') + P('(id) }\n') + P(' call.') + F('respond') + P('(') + T('HttpStatusCode') + P('.NoContent)\n') + P(' }\n') + P('}'); @@ -186,41 +202,51 @@ ${navHtml('tutorials')}

1Create the project

Start a plain Kotlin/Gradle project and add Ktor and Storm. The Ktor plugin manages the Ktor artifact versions, and the Storm BOM manages Storm's, so most dependencies need no version. We use H2 so there is nothing to install.

- ${editor({file: 'build.gradle.kts', tag: 'Gradle · Kotlin DSL', code: gradle})} + ${editor({ + file: 'build.gradle.kts', + tag: 'Gradle · Kotlin DSL', + copy: true, + variants: KOTLIN_VARIANTS.map((v) => ({label: v.label, code: gradleFor(v), selected: v.selected})), + })}

2Create the schema

-

Storm maps to an existing schema rather than generating one, which keeps migrations under your control. For this tutorial a small script is enough; H2 runs it on startup, so there is no migration tool to set up yet.

- ${editor({file: 'schema.sql', tag: 'SQL', code: schema})} +

Storm maps to an existing schema rather than generating one, which keeps migrations under your control. For this tutorial a small script is enough; H2 runs it on startup, so there is no migration tool to set up yet. On another database, pick it in the block below.

+ ${editor({ + file: 'schema.sql', + tag: 'SQL', + copy: true, + variants: DATABASE_VARIANTS.map((d) => ({label: d.label, code: schemaFor(d), selected: d.selected})), + })}

3Model the domain

Two immutable data classes. A Bookmark belongs to a Folder; marking that reference @FK is the whole relationship. Field names map to columns automatically, so folder becomes the folder_id column.

- ${editor({file: 'model.kt', tag: 'Kotlin', code: entities})} + ${editor({file: 'model.kt', tag: 'Kotlin', code: entities, copy: true})}

4Point Storm at the database

The Storm Ktor plugin reads its DataSource from application.conf. No wiring code: install(Storm) builds a connection pool and, because the metamodel processor already indexed them, registers your repositories.

- ${editor({file: 'application.conf', tag: 'HOCON', code: conf})} + ${editor({file: 'application.conf', tag: 'HOCON', code: conf, copy: true})}

5Add a repository

-

Extend EntityRepository and every CRUD method comes for free. Add your own one-line queries on top; Bookmark_ is the compile-time metamodel, so a typo in a field name fails to compile.

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

Extend EntityRepository and every CRUD method comes for free. Add your own one-line queries on top; Bookmark_ is the compile-time metamodel, so a typo in a field name fails to compile. FolderRepository shows the minimal case: one line, nothing to implement.

+ ${editor({file: 'Repositories.kt', tag: 'Kotlin', code: repo, copy: true})}

6Wire up the application

A standard Ktor main and module. Install Storm, register Storm's Jackson module so entities serialize cleanly, and mount the routes.

- ${editor({file: 'Application.kt', tag: 'Kotlin', code: app})} + ${editor({file: 'Application.kt', tag: 'Kotlin', code: app, copy: true})}

7Write the routes

-

Read straight from the ORM, write inside transaction { }. Because Ktor and Storm are both coroutine-based, the transaction rides the request's coroutine with no proxies or annotations. call.orm and repository<T>() are extensions available right in the handler.

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

Read straight from the ORM, write inside transaction { }. Because Ktor and Storm are both coroutine-based, the transaction rides the request's coroutine with no proxies or annotations. repository<T>() is an extension available right in the handler.

+ ${editor({file: 'Routes.kt', tag: 'Kotlin', code: routes, copy: true})}

The list endpoint is where Storm earns its keep. One call, one query: the folder for every bookmark is joined in, so there is no N+1 to discover later. Toggle Show SQL to see exactly what runs.

- ${editor({file: 'BookmarkRepository.kt', tag: 'Kotlin', code: listCode, sql: listSql})} + ${editor({file: 'BookmarkRepository.kt', tag: 'Kotlin', code: listCode, sql: listSql, copy: true})}

8Run it

Start the server and exercise it with curl. The created bookmark comes back with its full folder object, loaded in the same query that fetched the bookmark.

- ${editor({file: 'terminal', tag: 'shell', code: run})} + ${editor({file: 'terminal', tag: 'shell', code: run, copy: true})}

9Test it

storm-ktor-test spins up an in-memory database from your schema script and captures the SQL. Here we assert the create path is a single INSERT, so an accidental N+1 or extra round-trip fails the build rather than slipping into production.

- ${editor({file: 'BookmarkRoutesTest.kt', tag: 'Kotlin · test', code: test})} + ${editor({file: 'BookmarkRoutesTest.kt', tag: 'Kotlin · test', code: test, copy: true})}

You built it

An empty folder to a running, tested API: two entities, a joined relationship, four routes, and a repository, with no persistence context, no proxies, and no N+1. From here:

@@ -233,8 +259,8 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/exposed-entities.js b/website/src/pages/tutorials/exposed-entities.js index 04a30ec07..82a34f235 100644 --- a/website/src/pages/tutorials/exposed-entities.js +++ b/website/src/pages/tutorials/exposed-entities.js @@ -150,7 +150,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/exposed-ktor.js b/website/src/pages/tutorials/exposed-ktor.js index 9a248e568..2f75c3cad 100644 --- a/website/src/pages/tutorials/exposed-ktor.js +++ b/website/src/pages/tutorials/exposed-ktor.js @@ -102,7 +102,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/exposed-n-plus-one.js b/website/src/pages/tutorials/exposed-n-plus-one.js index f27d1c6d1..3fab1e34b 100644 --- a/website/src/pages/tutorials/exposed-n-plus-one.js +++ b/website/src/pages/tutorials/exposed-n-plus-one.js @@ -124,7 +124,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/exposed-queries.js b/website/src/pages/tutorials/exposed-queries.js index db44bf47f..7969a3b0a 100644 --- a/website/src/pages/tutorials/exposed-queries.js +++ b/website/src/pages/tutorials/exposed-queries.js @@ -123,7 +123,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/exposed-schema.js b/website/src/pages/tutorials/exposed-schema.js index 50d25d123..4251d3e74 100644 --- a/website/src/pages/tutorials/exposed-schema.js +++ b/website/src/pages/tutorials/exposed-schema.js @@ -82,7 +82,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/exposed-transactions.js b/website/src/pages/tutorials/exposed-transactions.js index b613c6668..81fce88e9 100644 --- a/website/src/pages/tutorials/exposed-transactions.js +++ b/website/src/pages/tutorials/exposed-transactions.js @@ -133,7 +133,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/exposed-upserts.js b/website/src/pages/tutorials/exposed-upserts.js index 05914a4ea..91273d2dd 100644 --- a/website/src/pages/tutorials/exposed-upserts.js +++ b/website/src/pages/tutorials/exposed-upserts.js @@ -111,7 +111,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/index.js b/website/src/pages/tutorials/index.js index 6080029c4..48b9aa22c 100644 --- a/website/src/pages/tutorials/index.js +++ b/website/src/pages/tutorials/index.js @@ -165,7 +165,7 @@ ${navHtml('tutorials')} Composite and natural keys Entity caching and dirty checking -

Want one of these sooner, or a topic that is not listed? Open an issue on GitHub.

+

Want one of these sooner, or a topic that is not listed? Open an issue on GitHub.

${FOOT_HTML} diff --git a/website/src/pages/tutorials/json-columns.js b/website/src/pages/tutorials/json-columns.js index 7fb2cdfb5..0a72498bc 100644 --- a/website/src/pages/tutorials/json-columns.js +++ b/website/src/pages/tutorials/json-columns.js @@ -100,7 +100,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/mapped-collections.js b/website/src/pages/tutorials/mapped-collections.js index 841e10ed1..a57bbd99f 100644 --- a/website/src/pages/tutorials/mapped-collections.js +++ b/website/src/pages/tutorials/mapped-collections.js @@ -142,7 +142,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/n-plus-one.js b/website/src/pages/tutorials/n-plus-one.js index 7e6806df0..ddc96168e 100644 --- a/website/src/pages/tutorials/n-plus-one.js +++ b/website/src/pages/tutorials/n-plus-one.js @@ -179,7 +179,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/observability.js b/website/src/pages/tutorials/observability.js index 5336a803d..eeb10d7f3 100644 --- a/website/src/pages/tutorials/observability.js +++ b/website/src/pages/tutorials/observability.js @@ -74,7 +74,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/optimistic-locking.js b/website/src/pages/tutorials/optimistic-locking.js index d5e0c18a7..7f81da90d 100644 --- a/website/src/pages/tutorials/optimistic-locking.js +++ b/website/src/pages/tutorials/optimistic-locking.js @@ -119,7 +119,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/pagination.js b/website/src/pages/tutorials/pagination.js index 49f903525..df9910f8d 100644 --- a/website/src/pages/tutorials/pagination.js +++ b/website/src/pages/tutorials/pagination.js @@ -124,7 +124,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/projections.js b/website/src/pages/tutorials/projections.js index e834099da..2a6667656 100644 --- a/website/src/pages/tutorials/projections.js +++ b/website/src/pages/tutorials/projections.js @@ -164,7 +164,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/query-results.js b/website/src/pages/tutorials/query-results.js index f0bf7a7f8..f92bfe7e1 100644 --- a/website/src/pages/tutorials/query-results.js +++ b/website/src/pages/tutorials/query-results.js @@ -139,7 +139,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/sealed-entities.js b/website/src/pages/tutorials/sealed-entities.js index 1bf23bc0c..475f47372 100644 --- a/website/src/pages/tutorials/sealed-entities.js +++ b/website/src/pages/tutorials/sealed-entities.js @@ -97,7 +97,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/sql-templates.js b/website/src/pages/tutorials/sql-templates.js index d30013049..16e972b0d 100644 --- a/website/src/pages/tutorials/sql-templates.js +++ b/website/src/pages/tutorials/sql-templates.js @@ -127,7 +127,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/streaming.js b/website/src/pages/tutorials/streaming.js index ebd331642..c184a22cd 100644 --- a/website/src/pages/tutorials/streaming.js +++ b/website/src/pages/tutorials/streaming.js @@ -87,7 +87,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/testing.js b/website/src/pages/tutorials/testing.js index 7fffc9a6b..bdf5c11fd 100644 --- a/website/src/pages/tutorials/testing.js +++ b/website/src/pages/tutorials/testing.js @@ -88,7 +88,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/transactions.js b/website/src/pages/tutorials/transactions.js index 6b2a47ecf..7c6b39bfe 100644 --- a/website/src/pages/tutorials/transactions.js +++ b/website/src/pages/tutorials/transactions.js @@ -145,7 +145,7 @@ ${navHtml('tutorials')} diff --git a/website/src/pages/tutorials/upserts.js b/website/src/pages/tutorials/upserts.js index 01655d6ec..841e22a1e 100644 --- a/website/src/pages/tutorials/upserts.js +++ b/website/src/pages/tutorials/upserts.js @@ -119,7 +119,7 @@ ${navHtml('tutorials')} diff --git a/website/versioned_docs/version-1.12.0/comparison.md b/website/versioned_docs/version-1.12.0/comparison.md index e6a19ddf6..2e01bdc5f 100644 --- a/website/versioned_docs/version-1.12.0/comparison.md +++ b/website/versioned_docs/version-1.12.0/comparison.md @@ -15,6 +15,7 @@ The following tables provide a side-by-side comparison of concrete features acro |---------|-------|-----|-------------|---------|------|------|--------|---------|-------| | Lines per entity | ~5 | ~301 | ~301 | ~20+ | Generated | ~15 | ~10 | ~12 | ~15 | | Immutable entities | Yes | No | No | Yes | Yes | Yes | Yes | DSL only | No | +| Session state | None | Persistence context | Via JPA | None | None | None | None | DAO only | Entity tracking | | Polymorphism | Yes2 | Yes | Via JPA | No | No | No | No8 | No | No | | Automatic relationships | Yes | Yes3 | Via JPA | No | No | No | Yes | DAO only | No | | Cascade persist | No | Yes | Yes | No | No | No | Yes | No | No | @@ -33,8 +34,9 @@ The following tables provide a side-by-side comparison of concrete features acro | Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Jimmer | Exposed | Ktorm | |---------|-------|-----|-------------|---------|------|------|--------|---------|-------| | Type-safe queries | Yes | Criteria | No | No | Yes | No | Yes | Yes | Yes | -| SQL Templates | Yes | No | No | XML/Ann | Yes | Yes | Native9 | No | No | +| SQL / SQL templates | Yes | Native queries | Via JPA | XML/Ann | Yes | Yes | Native9 | exec() | Raw JDBC | | N+1 prevention | Yes | No | No | No | Manual | Manual | Yes | No | No | +| Query across relations | One line | JPQL/Criteria | Derived/JPQL | Manual SQL | Path joins | Manual SQL | Implicit joins | Manual joins | Manual joins | | Lazy loading | Refs | Yes | Yes | No | No | No | Fetchers | Yes | Yes | | Scrolling | Yes | No | Yes | No | Yes | No | No | No | No | | JSON columns | Yes | Yes4 | Via JPA | Manual | Yes | Module | Yes | Yes | Module |