From c9838b61f5622abd3759d9d5bef65e643c5d4322 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Wed, 15 Jul 2026 09:01:37 +0200 Subject: [PATCH 1/4] docs: compare JPA cascades with write sets, as reference page and tutorial - New reference page under Resources: the same Owner-Pet-Visit graph in both models, the direction inversion (cascades travel parent-to-child along collections, write set discovery follows FK fields upward), the managed-graph vs explicit-operation trade-off stated symmetrically, verb-by-verb mapping, and a cascade translation table - New tutorial at /tutorials/object-graphs in the From JPA series, with the hub card and series count - Cross-links from the write-sets reference and the JPA migration guide The write-set snippets use the vararg call shape planned for 1.13.0; the vararg overloads must land before the release ships these pages. --- docs/jpa-cascades-vs-write-sets.md | 288 +++++++++++++++++++ docs/migration-from-jpa.md | 3 + docs/write-sets.md | 2 +- website/sidebars.ts | 1 + website/src/pages/tutorials/index.js | 7 +- website/src/pages/tutorials/object-graphs.js | 172 +++++++++++ 6 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 docs/jpa-cascades-vs-write-sets.md create mode 100644 website/src/pages/tutorials/object-graphs.js diff --git a/docs/jpa-cascades-vs-write-sets.md b/docs/jpa-cascades-vs-write-sets.md new file mode 100644 index 000000000..6650fb6ba --- /dev/null +++ b/docs/jpa-cascades-vs-write-sets.md @@ -0,0 +1,288 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# JPA Cascades vs Write Sets + +JPA's cascading and Storm's write sets solve the same problem: an object graph that spans several tables must reach the database in the right order, with generated keys flowing from parents to children. The two solutions work from opposite philosophies. This tutorial implements the same use case with both, then examines what each choice implies. + +If you are new to write sets, [Write Sets](write-sets.md) covers the API itself. For the broader migration story, see [Migration from JPA](migration-from-jpa.md). + +## The Task + +An `Owner` with two `Pet`s, one of which has a `Visit`. Four rows across three tables: the owner first, then the pets carrying the generated owner key, then the visit carrying a pet key. + +## The JPA Version + +In JPA the graph is a class model with associations in both directions, and the cascade configuration on those associations decides how far each operation travels: + +```java +@Entity +public class Owner { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String firstName; + private String lastName; + + @OneToMany(mappedBy = "owner", cascade = CascadeType.PERSIST) + private List pets = new ArrayList<>(); + + public void addPet(Pet pet) { + pets.add(pet); + pet.setOwner(this); + } + // constructors, getters, setters +} + +@Entity +public class Pet { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + private LocalDate birthDate; + + @ManyToOne(fetch = FetchType.LAZY) + private Owner owner; + + @OneToMany(mappedBy = "pet", cascade = CascadeType.PERSIST) + private List visits = new ArrayList<>(); + + public void addVisit(Visit visit) { + visits.add(visit); + visit.setPet(this); + } + // constructors, getters, setters +} + +@Entity +public class Visit { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private LocalDate visitDate; + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + private Pet pet; + // constructors, getters, setters +} +``` + +Persisting the graph is one call on the root: + +```java +var owner = new Owner("Alice", "Bond"); +var wolfie = new Pet("Wolfie", birthDate); +var rex = new Pet("Rex", birthDate); +owner.addPet(wolfie); +owner.addPet(rex); +wolfie.addVisit(new Visit(today, "Check-up")); + +entityManager.persist(owner); +``` + +`persist` makes the owner managed and propagates along every association marked `CascadeType.PERSIST`, so the pets and the visit become managed too. The INSERT statements run at flush time, parents before children, and the provider writes each generated key into the managed child instances. + +Note what carried the graph: the parent-to-child collections (`owner.pets`, `pet.visits`), kept consistent with the child-to-parent references by the `addPet` and `addVisit` helpers. And note where the behavior lives: on the mapping. Every `persist` that reaches an `Owner`, from any call site, cascades the same way. + +## The Storm Version + +Storm entities are records or data classes that model rows, so they reference only their parents, exactly as the foreign key columns do: + + + + +```kotlin +data class Owner( + @PK val id: Int = 0, + val firstName: String, + val lastName: String +) : Entity + +data class Pet( + @PK val id: Int = 0, + val name: String, + val birthDate: LocalDate, + @FK val owner: Owner +) : Entity + +data class Visit( + @PK val id: Int = 0, + val visitDate: LocalDate, + val description: String, + @FK val pet: Pet +) : Entity +``` + + + + +```java +record Owner(@PK Integer id, String firstName, String lastName) + implements Entity {} + +record Pet(@PK Integer id, String name, LocalDate birthDate, @FK Owner owner) + implements Entity {} + +record Visit(@PK Integer id, LocalDate visitDate, String description, @FK Pet pet) + implements Entity {} +``` + + + + +The graph is built by holding parent instances, and written with one call: + + + + +```kotlin +val owner = Owner(firstName = "Alice", lastName = "Bond") +val wolfie = Pet(name = "Wolfie", birthDate = birthDate, owner = owner) +val rex = Pet(name = "Rex", birthDate = birthDate, owner = owner) +val visit = Visit(visitDate = today, description = "Check-up", pet = wolfie) + +orm.writeSet().insert(wolfie, rex, visit) +``` + + + + +```java +var owner = new Owner(0, "Alice", "Bond"); +var wolfie = new Pet(0, "Wolfie", birthDate, owner); +var rex = new Pet(0, "Rex", birthDate, owner); +var visit = new Visit(0, today, "Check-up", wolfie); + +orm.writeSet().insert(wolfie, rex, visit); +``` + + + + +The owner was never passed. It is discovered: `insert` extends the explicit members with every unsaved entity reachable through their foreign key fields, the *insertion closure*. Storm orders the result by foreign key dependencies, executes one batch per entity type per dependency level (owners, then pets, then visits), and propagates generated keys by instance identity: both pets hold the same `owner` instance, so both rows receive the same generated key. + +There is no cascade configuration anywhere. Nothing on `Owner` says that pets depend on it; the pet values say so themselves. + +## The Philosophical Difference + +### Cascades point down, write sets point up + +A JPA object model references in both directions. Parents hold collections of children so that operations can travel downward, which is the direction a cascade needs. A Storm entity references only upward: a pet names its owner because the pet row holds the foreign key, and that is the only graph there is. + +This one inversion explains most of what follows: + +- **Insert discovery needs no configuration in Storm** because every value declares its own dependencies. There is nothing to over-cascade into: the closure contains exactly the unsaved entities your values reference, transitively. +- **Delete discovery does not exist in Storm** because a value never names its children. `remove` deletes exactly the entities you pass, children before parents. Reactive cleanup of dependents belongs to the schema: declare `ON DELETE CASCADE` on the foreign key constraint and the database maintains it under every writer, not just your application. + +### A managed graph vs an explicit operation + +In JPA, persistence is a property of the object model. You configure once, on the mappings, how operations travel the graph. A persistence context keeps the loaded graph and the database converged, and the writes are derived from state: new managed entities, dirty fields, orphaned children, all collected and ordered at flush time. Cascades extend that convergence across associations. The call site stays small, `persist(owner)`, because the mapping and the session carry the knowledge. + +In Storm, persistence is an operation on values. There is no session and no managed state; entities are plain values that describe rows. A write set is a single call whose inputs fully determine what happens: the verb, the members you pass, and the closure those values imply. The call site carries the knowledge, and the model stays free of behavior. + +Neither choice is free. The price of Storm's model is that every call site states its intent; there is no per-association default to configure once and rely on everywhere. The price of JPA's model is action at a distance: reading a call site is not enough to know what it writes, because the mapping and the current session state complete the sentence. + +### Consequences side by side + +| Question | JPA cascades | Storm write sets | +|----------|--------------|------------------| +| Where is the behavior declared? | On the mapping, once, for every call site | At the call site, per operation | +| What carries the graph? | Object references in both directions | Foreign key fields; a value names its parents | +| When do statements run? | At flush time, ordered by the provider | At the call, in dependency-ordered batches | +| How do children get parent keys? | The provider updates managed instances in place | By instance identity; the `AndFetch` variants return keyed values | +| What counts as new? | Entity state: transient, managed, detached | A local test: default primary key and an auto-generated strategy | +| Cascading deletes | `CascadeType.REMOVE`, `orphanRemoval` | `ON DELETE CASCADE` in the schema; ORM deletes stay explicit | + +## Operation by Operation + +### `persist` with PERSIST becomes `insert` + +Shown above. One difference follows from immutability: JPA assigns the generated key to your instance, while Storm values never change. Use the `AndFetch` variants to get the persisted state back: + + + + +```kotlin +val saved: Visit = orm.writeSet().insertAndFetch(visit) +// saved.pet and saved.pet.owner carry the generated keys; visit is untouched +``` + + + + +```java +Visit saved = orm.writeSet().insertAndFetch(visit); +// saved.pet() and saved.pet().owner() carry the generated keys; visit is untouched +``` + + + + +### `merge` with MERGE becomes `update` or `upsert` + +JPA's `merge` copies detached state onto a managed instance and, with `CascadeType.MERGE`, walks the associations; whether a row is inserted or updated follows from entity state. Storm splits that decision into two explicit verbs: `update` for rows you know exist, and [`upsert`](upserts.md) when the database should resolve it atomically. Both write exactly the explicit members. Referenced entities provide foreign key values but are never written implicitly, and `update` rejects unsaved members instead of quietly inserting them. + +This is the sharpest instinct to retrain. An updated `Owner` held inside a `Pet` passed to `update` is not written: a keyed referenced entity contributes only its primary key, so the owner's changes stay in memory. Where a JPA session would eventually flush a managed owner even without a cascade, Storm has no session and writes what you name, nothing else. When both changed, name both; each row is written with its own dirty check, so unchanged members still cost nothing: + + + + +```kotlin +orm.writeSet().update(pet, owner) +``` + + + + +```java +orm.writeSet().update(pet, owner); +``` + + + + +### `remove` with REMOVE becomes `remove` plus the schema + +`writeSet().remove` deletes the entities you name, children before parents, so a member referencing another member never trips a constraint: + + + + +```kotlin +orm.writeSet().remove(owner, wolfie, visit) // executed as: visit, wolfie, owner +``` + + + + +```java +orm.writeSet().remove(owner, wolfie, visit); // executed as: visit, wolfie, owner +``` + + + + +What it does not do is walk the graph looking for dependents. `orphanRemoval` has no counterpart either: Storm entities hold no child collections, so there is no collection mutation to react to. Declare the reaction where the database can enforce it, on the constraint, or delete the rows explicitly. + +## What Stays the Same + +Some instincts carry over directly: + +- **Correlation by instance.** JPA correlates parent and child through the managed object; write sets correlate through instance identity. In both worlds, two structurally equal but distinct new instances describe two rows, and children link to a parent by holding it. +- **Skipping clean rows.** JPA's flush skips managed entities that have not changed. Storm's `update` does the same through transaction-scoped [dirty checking](dirty-checking.md). +- **Per-row semantics.** Every row a write set touches goes through the same path as the corresponding repository operation, including [entity callbacks](entity-lifecycle.md). +- **Transactions.** A JPA flush runs inside the surrounding transaction. A write set executes several statements and is not atomic by itself, so wrap it in a [transaction](transactions.md) when the set must commit or fail as one. + +One instinct inverts. JPA reports an unresolvable reference late: a flush that finds an association pointing at a transient entity outside any cascade fails at flush time. Storm validates the plan first and raises a descriptive error before any statement runs, for example when `update` receives an unsaved member or when a dependency cycle cannot be ordered. + +## Translating Your Cascades + +| In your JPA model | In Storm | +|-------------------|----------| +| `CascadeType.PERSIST` on a collection | Nothing to declare. Build children holding the parent instance and pass them to `writeSet().insert` | +| `CascadeType.MERGE` | `writeSet().update`, or `writeSet().upsert` when the database should decide | +| `CascadeType.REMOVE`, `orphanRemoval` | `ON DELETE CASCADE` on the constraint; explicit `writeSet().remove` otherwise | +| `CascadeType.ALL` | Decide per call site; there is no per-association default | +| `addPet`/`addVisit` helpers keeping both sides in sync | Nothing to maintain; only child-to-parent references exist | +| Flush-time write-behind | Statements run at the call, in dependency order | + +The mechanical translation is small: drop the collections, keep the parent references, and say at each call site what you want written. The conceptual translation is the subject of this page: the graph moves out of the mappings and into your values, and the write moves out of the session and into a call you can read. diff --git a/docs/migration-from-jpa.md b/docs/migration-from-jpa.md index 592715c89..be4b83ee8 100644 --- a/docs/migration-from-jpa.md +++ b/docs/migration-from-jpa.md @@ -16,6 +16,7 @@ This guide helps you transition from JPA/Hibernate to Storm. The two frameworks | JPQL / Criteria API | Type-safe DSL / SQL Templates | | EntityManager | ORMTemplate | | `@OneToMany`, `@ManyToOne` | `@FK` annotation | +| Cascade types on mappings | Write sets per call site | ## Entity Migration @@ -358,6 +359,8 @@ Storm approach (query the "many" side): val orders = orm.findAll(Order_.user eq user) ``` +Without child collections there is also nothing to cascade over. Persisting a graph of related entities is a per-call decision made with [write sets](write-sets.md); [JPA Cascades vs Write Sets](jpa-cascades-vs-write-sets.md) walks through the translation and the reasoning behind it. + ## Transaction Migration Storm supports both Spring's `@Transactional` annotation and its own programmatic `transaction {}` block. If you are migrating a Spring application, your existing `@Transactional` annotations continue to work unchanged. Storm participates in the same Spring-managed transaction. The programmatic API is useful when you want explicit control over isolation levels, propagation, or when working outside of Spring entirely. diff --git a/docs/write-sets.md b/docs/write-sets.md index 479e6b335..f4b0fd7c8 100644 --- a/docs/write-sets.md +++ b/docs/write-sets.md @@ -7,7 +7,7 @@ Inserting an object graph that spans several tables normally requires careful ch A write set applies one write operation to a heterogeneous collection of entities. Storm partitions the entities by type, orders them by their foreign key dependencies, and writes them in dependency-ordered batches, propagating generated primary keys to dependent entities. `insert` and `upsert` extend the *explicit members* (the entities you supply) with *discovered members*: unsaved entities transitively reachable through insertable foreign key fields. This discovery is called the *insertion closure*. `update` and `remove` operate on the explicit members only. -Write sets are not a cascade in the JPA sense. There are no mapping annotations, no persistence context and no session-wide cascade: all writes derive from the entities supplied to the call and, for insert and upsert, their insertion closure. The per-row semantics of every verb are identical to the corresponding repository operation, including entity callbacks and dirty checking. +Write sets are not a cascade in the JPA sense. There are no mapping annotations, no persistence context and no session-wide cascade: all writes derive from the entities supplied to the call and, for insert and upsert, their insertion closure. The per-row semantics of every verb are identical to the corresponding repository operation, including entity callbacks and dirty checking. Coming from JPA? [JPA Cascades vs Write Sets](jpa-cascades-vs-write-sets.md) compares the two models side by side. A write set is obtained from the ORM template, or from any repository (`writeSet()` on a repository is a convenience that delegates to the underlying template; it is not scoped to the repository's entity type): diff --git a/website/sidebars.ts b/website/sidebars.ts index 443d67e48..d0c7d1d50 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -78,6 +78,7 @@ const sidebars: SidebarsConfig = { 'comparison', 'faq', 'migration-from-jpa', + 'jpa-cascades-vs-write-sets', 'ai', 'ai-reference', 'database-and-mcp', diff --git a/website/src/pages/tutorials/index.js b/website/src/pages/tutorials/index.js index 48b9aa22c..1b618c082 100644 --- a/website/src/pages/tutorials/index.js +++ b/website/src/pages/tutorials/index.js @@ -21,7 +21,7 @@ ${navHtml('tutorials')}

Task-focused tutorials for engineers coming from JPA, Hibernate, or Exposed, plus how-to recipes for Storm itself. Each comparison takes a persistence task you already know, shows both approaches side by side, and lets you inspect the SQL they produce.

@@ -78,6 +78,11 @@ ${navHtml('tutorials')}
owner.pets is one property access, and that is genuinely convenient. Storm queries the association instead: one line that loads when you decide, composes with filters and paging, and survives the join table growing columns.
JPA to Storm6 min readKotlin
+ +
Persisting object graphs: cascades vs write sets
+
CascadeType configures, once per mapping, how far every persist travels. A Storm write set decides per call: pass the entities, and unsaved parents are discovered, dependency-ordered, and keyed by instance identity.
+
JPA to Storm6 min readKotlin
+
Full SQL without giving up safety
Native queries return untyped rows and invite concatenation. Storm's SQL templates bind interpolated values safely, map rows to data classes, and expand your entities into columns and joins.
diff --git a/website/src/pages/tutorials/object-graphs.js b/website/src/pages/tutorials/object-graphs.js new file mode 100644 index 000000000..3decf1bdf --- /dev/null +++ b/website/src/pages/tutorials/object-graphs.js @@ -0,0 +1,172 @@ +import React from 'react'; +import { + TutorialPage, navHtml, FOOT_HTML, editor, + K, T, S, C, F, N, A, P, QK, QQ, QC, +} from '../../components/tutorial/tutorialTheme'; + +// Tutorial: persisting object graphs, JPA cascades vs Storm write sets. + +const TITLE = 'Persisting Object Graphs: JPA Cascades vs Storm Write Sets'; +const DESC = + 'JPA cascades configure, once per mapping, how far every persist travels. A ' + + 'Storm write set makes that decision per call: pass the entities, and unsaved ' + + 'parents are discovered, dependency-ordered, and keyed automatically.'; + +const CODE_JPA_MODEL = [ + C('// The graph lives in the mapping: collections point down, cascade rides them\n'), + A('@Entity'), P('\n'), + K('class '), T('Owner'), P('(\n'), + P(' '), A('@Id'), P(' '), A('@GeneratedValue'), P(' '), K('var '), P('id: '), T('Long'), P(' = '), N('0'), P(',\n'), + P(' '), K('var '), P('firstName: '), T('String'), P(',\n'), + P(') {\n'), + P(' '), A('@OneToMany'), P('(mappedBy = '), S('"owner"'), P(', cascade = ['), T('CascadeType'), P('.PERSIST])\n'), + P(' '), K('var '), P('pets: '), T('MutableList'), P('<'), T('Pet'), P('> = '), F('mutableListOf'), P('()\n\n'), + P(' '), K('fun '), F('addPet'), P('(pet: '), T('Pet'), P(') { pets += pet; pet.owner = '), K('this'), P(' }\n'), + P('}\n\n'), + A('@Entity'), P('\n'), + K('class '), T('Pet'), P('(\n'), + P(' '), A('@Id'), P(' '), A('@GeneratedValue'), P(' '), K('var '), P('id: '), T('Long'), P(' = '), N('0'), P(',\n'), + P(' '), K('var '), P('name: '), T('String'), P(',\n'), + P(' '), A('@ManyToOne'), P(' '), K('var '), P('owner: '), T('Owner'), P('? = '), K('null'), P(',\n'), + P(')'), +].join(''); + +const CODE_JPA_PERSIST = [ + C('// One call on the root; the mapping decides how far it travels\n'), + K('val '), P('owner = '), T('Owner'), P('(firstName = '), S('"Alice"'), P(')\n'), + P('owner.'), F('addPet'), P('('), T('Pet'), P('(name = '), S('"Wolfie"'), P('))\n'), + P('owner.'), F('addPet'), P('('), T('Pet'), P('(name = '), S('"Rex"'), P('))\n\n'), + P('entityManager.'), F('persist'), P('(owner) '), C('// pets become managed too; SQL runs at flush'), +].join(''); + +const SQL_JPA_PERSIST = [ + QC('-- at flush time, parents before children'), '\n', + QK('INSERT INTO'), ' owner (first_name) ', QK('VALUES'), ' (', QQ('?'), ')\n', + QK('INSERT INTO'), ' pet (name, owner_id) ', QK('VALUES'), ' (', QQ('?'), ', ', QQ('?'), ')\n', + QK('INSERT INTO'), ' pet (name, owner_id) ', QK('VALUES'), ' (', QQ('?'), ', ', QQ('?'), ')\n\n', + QC('-- the provider walked owner.pets; every persist that reaches an Owner,'), '\n', + QC('-- from any call site, cascades exactly the same way'), +].join(''); + +const CODE_STORM_MODEL = [ + C('// The graph lives in the values: a row names its parents, like the FK column does\n'), + K('data class '), T('Owner'), P('(\n'), + P(' '), A('@PK'), P(' '), K('val '), P('id: '), T('Int'), P(' = '), N('0'), P(',\n'), + P(' '), K('val '), P('firstName: '), T('String'), P(',\n'), + P(') : '), T('Entity'), P('<'), T('Int'), P('>\n\n'), + K('data class '), T('Pet'), 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(' '), A('@FK'), P(' '), K('val '), P('owner: '), T('Owner'), P(',\n'), + P(') : '), T('Entity'), P('<'), T('Int'), P('>\n\n'), + K('data class '), T('Visit'), P('(\n'), + P(' '), A('@PK'), P(' '), K('val '), P('id: '), T('Int'), P(' = '), N('0'), P(',\n'), + P(' '), K('val '), P('description: '), T('String'), P(',\n'), + P(' '), A('@FK'), P(' '), K('val '), P('pet: '), T('Pet'), P(',\n'), + P(') : '), T('Entity'), P('<'), T('Int'), P('>'), +].join(''); + +const CODE_STORM_INSERT = [ + C('// Build the graph by holding parent instances, then write it in one call\n'), + K('val '), P('owner = '), T('Owner'), P('(firstName = '), S('"Alice"'), P(')\n'), + K('val '), P('wolfie = '), T('Pet'), P('(name = '), S('"Wolfie"'), P(', owner = owner)\n'), + K('val '), P('rex = '), T('Pet'), P('(name = '), S('"Rex"'), P(', owner = owner) '), C('// same instance\n'), + K('val '), P('visit = '), T('Visit'), P('(description = '), S('"Check-up"'), P(', pet = wolfie)\n\n'), + P('orm.'), F('writeSet'), P('().'), F('insert'), P('(wolfie, rex, visit)'), +].join(''); + +const SQL_STORM_INSERT = [ + QC('-- one batch per entity type per dependency level'), '\n', + QK('INSERT INTO'), ' owner (first_name) ', QK('VALUES'), ' (', QQ('?'), ')\n', + QK('INSERT INTO'), ' pet (name, owner_id) ', QK('VALUES'), ' (', QQ('?'), ', ', QQ('?'), ') ', QC('-- batch of 2'), '\n', + QK('INSERT INTO'), ' visit (description, pet_id) ', QK('VALUES'), ' (', QQ('?'), ', ', QQ('?'), ')\n\n', + QC('-- the owner was never passed: the pet values imply it (insertion closure),'), '\n', + QC('-- and both pets hold the same instance, so both rows share its generated key'), +].join(''); + +const CODE_STORM_FETCH = [ + C('// Values never mutate: fetch the persisted graph back when you need the keys\n'), + K('val '), P('saved: '), T('Visit'), P(' = orm.'), F('writeSet'), P('().'), F('insertAndFetch'), P('(visit)\n'), + C('// saved.pet.owner.id carries the generated key; visit itself is untouched'), +].join(''); + +const CODE_STORM_REMOVE = [ + C('// remove deletes exactly what you pass, children before parents\n'), + P('orm.'), F('writeSet'), P('().'), F('remove'), P('(owner, wolfie, visit)\n'), + C('// executed as: visit, wolfie, owner\n'), P('\n'), + C('// reactive cleanup of dependents is the schema\'s job:\n'), + C('// pet.owner_id REFERENCES owner(id) ON DELETE CASCADE'), +].join(''); + +const BODY = ` +${navHtml('tutorials')} + +
+
Tutorials/Object graphs
+

Persisting object graphs: cascades vs write sets

+

An object graph spanning several tables must reach the database in dependency order, with generated keys flowing from parents to children. JPA and Storm both solve it. They start from opposite ends.

+
Series · JPA to Storm6 min readKotlin
+ +

01The task

+

An Owner with two Pets, one of which has a Visit. Four rows across three tables: the owner first, then the pets carrying the generated owner key, then the visit carrying a pet key. Whatever tool you use has to order the writes and move the keys.

+ +

02The JPA way

+

In JPA the graph is a class model with associations in both directions, and cascade configuration on those associations decides how far each operation travels:

+ ${editor({file: 'Model.kt', tag: 'Kotlin · JPA', code: CODE_JPA_MODEL})} +

Persisting the graph is one call on the root. persist makes the owner managed, propagates along every association marked CascadeType.PERSIST, and the provider writes the statements at flush time, assigning each generated key to the managed instances:

+ ${editor({file: 'RegistrationService.kt', tag: 'Kotlin · JPA', code: CODE_JPA_PERSIST, sql: SQL_JPA_PERSIST})} +

Two things carried that graph. The parent-to-child collections, kept consistent with the child-to-parent references by the addPet helper. And the mapping, where the cascade lives: it is declared once and applies to every call site in the application. That is genuinely convenient, and it is also a decision you can no longer make per use case.

+ +

03The Storm way

+

Storm entities are immutable values that model rows, so they reference only their parents, exactly as the foreign key columns do. No collections, no back-references, no helpers keeping the two sides in sync:

+ ${editor({file: 'Model.kt', tag: 'Kotlin · Storm', code: CODE_STORM_MODEL})} +

A write set applies one operation to a heterogeneous collection of entities. Pass the leaves; unsaved parents are discovered through the foreign key fields:

+ ${editor({file: 'RegistrationService.kt', tag: 'Kotlin · Storm', code: CODE_STORM_INSERT, sql: SQL_STORM_INSERT})} +

The owner was never passed. insert extends the entities you supply with every unsaved entity reachable through their foreign key fields, the insertion closure. Storm orders the result by foreign key dependencies, executes one batch per entity type per level, and propagates generated keys by instance identity: both pets hold the same owner instance, so both rows receive the same key. Nothing on Owner says that pets depend on it; the pet values say so themselves.

+

One difference follows from immutability. JPA assigns generated keys to your instances; Storm values never change, so the AndFetch variants return the persisted state:

+ ${editor({file: 'RegistrationService.kt', tag: 'Kotlin · Storm', code: CODE_STORM_FETCH})} + +

04Cascades point down, write sets point up

+

The deeper difference is one of direction. A JPA model references in both directions because cascades travel parent to child, along the collections. A Storm value references only upward: a pet names its owner because the pet row holds the foreign key, and that is the only graph there is. Insert discovery therefore needs no configuration, a value declares its own dependencies. And delete discovery deliberately does not exist, because a value never names its children.

+

Behind that sits a difference of philosophy. In JPA, persistence is a property of the object model: you configure once how operations travel, and the session derives the writes from state at flush time. In Storm, persistence is an operation on values: a single call whose inputs fully determine what happens. Neither is free. Storm's price is that every call site states its intent; there is no per-association default to set once and rely on. JPA's price is action at a distance: the call site alone does not tell you what will be written, because the mapping and the session state complete the sentence.

+
A write set executes several statements and is not atomic by itself. Wrap it in transaction { } when the graph must commit or fail as one. Keys correlate by instance, not by equality: a copy() of an unsaved parent describes a second row.
+ +

05Updates, deletes, and orphanRemoval

+

The other verbs follow the same contract. Where merge with CascadeType.MERGE walks the associations and decides insert-or-update from entity state, Storm splits the decision into explicit verbs: update for rows you know exist, upsert when the database should resolve it atomically. Both write exactly the entities you pass, and update rejects unsaved members instead of quietly inserting them.

+

That includes the case that trips JPA instincts hardest: an updated owner held inside a pet you pass to update is not written. A keyed referenced entity contributes only its primary key, and there is no session that will flush the owner later. When both changed, name both: update(pet, owner). Each row is written with its own dirty check, so unchanged members still cost nothing.

+

remove deletes the entities you name, children before parents. What it does not do is walk the graph looking for dependents:

+ ${editor({file: 'CleanupService.kt', tag: 'Kotlin · Storm', code: CODE_STORM_REMOVE})} +

orphanRemoval has no counterpart by design: Storm entities hold no child collections, so there is no collection mutation to react to. Declare the reaction where the database can enforce it for every writer, on the constraint, or delete rows explicitly.

+ +

06Side by side

+ + + + + + + + +
JPA cascadesStorm write sets
Behavior declaredOn the mapping, for every call siteAt the call site, per operation
What carries the graphCollections and back-references, kept in syncFK fields; a value names its parents
When SQL runsAt flush timeAt the call, in dependency-ordered batches
Generated keysAssigned to managed instancesPropagated by instance identity; AndFetch returns keyed values
What counts as newEntity state: transient, managed, detachedLocal test: default id on an auto-generated key
Cascading deletesCascadeType.REMOVE, orphanRemovalExplicit remove; ON DELETE CASCADE in the schema
+ +

07Keep going

+

The reference documentation covers the mechanics in depth:

+ + +
+ +${FOOT_HTML} +`; + +export default function ObjectGraphsTutorial() { + return ; +} From 387bbcf6787dbc3cc79f2f33e462704f9b0bf2e6 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Wed, 15 Jul 2026 09:04:44 +0200 Subject: [PATCH 2/4] docs(website): unify the gold palette, center benchmark headers, style note code - One gold family across the site: the figure gradients, active chips, and link hovers all draw from #feeeb0 / #fbbf24 / #f59e0b (code-editor syntax tokens keep their own palette) - Center the benchmark matrix header row; data cells stay right-aligned - Inline code inside tutorial note boxes previously fell through to the Infima defaults and rendered as a light chip; it now uses the same dark chip as inline code elsewhere on the page --- website/src/components/tutorial/tutorialTheme.js | 2 +- website/src/pages/benchmarks.js | 4 ++-- website/src/pages/index.js | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/src/components/tutorial/tutorialTheme.js b/website/src/components/tutorial/tutorialTheme.js index b8ad68b69..79352f804 100644 --- a/website/src/components/tutorial/tutorialTheme.js +++ b/website/src/components/tutorial/tutorialTheme.js @@ -322,7 +322,7 @@ export const TUT_CSS = ` .storm-tut ul{color:var(--body);font-size:15.5px;line-height:1.75;margin:14px 0 0;padding-left:22px} .storm-tut li{margin-top:6px} .storm-tut li::marker{color:var(--faint)} - .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 p code,.storm-tut li code,.storm-tut td code,.storm-tut .note 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 diff --git a/website/src/pages/benchmarks.js b/website/src/pages/benchmarks.js index 921ebbc4d..7839a728b 100644 --- a/website/src/pages/benchmarks.js +++ b/website/src/pages/benchmarks.js @@ -374,12 +374,12 @@ const BM_CSS = ` .storm-tut .art h2{margin:52px 0 14px} .storm-tut .art h3{margin:44px 0 10px} .storm-tut .art h3 + p{margin-top:0} - .bm-stat b{background:linear-gradient(100deg,#fde68a,#fbbf24 55%,#f59e0b);-webkit-background-clip:text;background-clip:text;color:transparent} + .bm-stat b{background:linear-gradient(100deg,#feeeb0,#fbbf24 55%,#f59e0b);-webkit-background-clip:text;background-clip:text;color:transparent} .bm-matrix-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:14px;background:var(--panel);margin:22px 0 10px;padding:10px 12px} .art .bm-matrix,.art .bm-matrix thead,.art .bm-matrix tbody{background:none} .art .bm-matrix{width:100%;border-collapse:separate;border-spacing:3px;font-family:var(--mono);font-size:12px;margin:0} .art .bm-matrix th,.art .bm-matrix td{border:none;background:none;padding:9px 12px;text-align:right;white-space:nowrap} - .art .bm-matrix thead th{color:var(--muted);font-weight:600;padding:4px 12px;font-size:10.5px;letter-spacing:.04em} + .art .bm-matrix thead th{color:var(--muted);font-weight:600;padding:4px 12px;font-size:10.5px;letter-spacing:.04em;text-align:center} .art .bm-matrix thead th.storm{background:linear-gradient(100deg,#a78bfa,#818cf8 55%,#7dd3fc);-webkit-background-clip:text;background-clip:text;color:transparent} .art .bm-matrix thead th.jdbc{color:var(--faint);font-style:italic} .art .bm-matrix tbody th{text-align:left;color:var(--body);font-family:var(--sans);font-size:12.5px;font-weight:500} diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 843d3f67e..f93f26450 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -54,9 +54,9 @@ const CSS = ` .storm-home .vs-label{font-size:12.5px;color:var(--faint);margin-right:2px} .storm-home .vs-chips button{font-family:var(--mono);font-size:11.5px;color:var(--faint);border:1px solid var(--border-soft);background:none;border-radius:999px;padding:6px 12px;cursor:pointer;transition:all .15s ease} .storm-home .vs-chips button:hover{color:var(--muted);border-color:var(--border)} - .storm-home .vs-chips button.on{color:#fde68a;border-color:rgba(251,191,36,.65);background:rgba(251,191,36,.14);box-shadow:0 0 14px rgba(251,191,36,.18);font-weight:700} + .storm-home .vs-chips button.on{color:#feeeb0;border-color:rgba(251,191,36,.65);background:rgba(251,191,36,.14);box-shadow:0 0 14px rgba(251,191,36,.18);font-weight:700} .storm-home .vs-bench{margin-left:auto;font-size:12.5px;font-weight:600;color:#fbbf24;text-decoration:none;white-space:nowrap} - .storm-home .vs-bench:hover{text-decoration:underline;color:#fde68a} + .storm-home .vs-bench:hover{text-decoration:underline;color:#feeeb0} .storm-home .card.bcard{padding:18px 22px} .storm-home .bcard.bench{min-height:186px} .storm-home .bcard{position:relative;overflow:hidden} @@ -72,7 +72,7 @@ const CSS = ` .storm-home .bback{position:absolute;inset:0;padding:inherit;opacity:0;transform:translateY(10px);pointer-events:none} .storm-home .bcard.bench .bfront{position:absolute;inset:0;padding:inherit;opacity:0;transform:translateY(-10px);pointer-events:none} .storm-home .bcard.bench .bback{position:relative;inset:auto;padding:0;opacity:1;transform:none;pointer-events:auto} - .storm-home .bnum{font-size:42px;font-weight:800;line-height:1;margin-bottom:8px;letter-spacing:-.02em;background:linear-gradient(100deg,#fde68a,#fbbf24 55%,#f59e0b);-webkit-background-clip:text;background-clip:text;color:transparent} + .storm-home .bnum{font-size:42px;font-weight:800;line-height:1;margin-bottom:8px;letter-spacing:-.02em;background:linear-gradient(100deg,#feeeb0,#fbbf24 55%,#f59e0b);-webkit-background-clip:text;background-clip:text;color:transparent} .storm-home .bback a{color:var(--accent);text-decoration:none;font-weight:600;white-space:nowrap} .storm-home .bback a:hover{text-decoration:underline} /* Rotating hero line: all taglines live in the DOM, stacked in one grid cell From 625a4c57537a4f9143871775cc397845409b96b3 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Wed, 15 Jul 2026 09:18:20 +0200 Subject: [PATCH 3/4] docs: document the referenced-entity contract and unify on action terminology - One rule, stated in the WriteSet javadoc, the reference docs, and both comparison pages: a write set writes the entities you name, plus the entities your values make necessary. An unsaved referenced entity is necessary (its dependent cannot be written without its key, and a row that does not exist cannot be a stale copy); a keyed referenced entity never is: it is the state hydrated when the value was read, and treating that snapshot as write intent would silently overwrite newer database state - update/upsert javadoc now state explicitly that a modified referenced entity is not written and that changed entities must be passed as explicit members - Rename the write-set operation terminology from verb to action across javadoc, KDoc, docs, tutorial pages, and the private Action enum in WriteSetImpl (write-set test suite passes: 25 tests) --- docs/jpa-cascades-vs-write-sets.md | 8 +++-- docs/write-sets.md | 10 +++--- .../orm/core/repository/RepositoryLookup.java | 2 +- .../core/repository/impl/WriteSetImpl.java | 36 +++++++++---------- .../src/main/java/st/orm/WriteSet.java | 19 ++++++++-- .../st/orm/repository/RepositoryLookup.java | 2 +- .../st/orm/repository/RepositoryLookup.kt | 6 ++-- website/src/pages/tutorials/object-graphs.js | 3 +- 8 files changed, 52 insertions(+), 34 deletions(-) diff --git a/docs/jpa-cascades-vs-write-sets.md b/docs/jpa-cascades-vs-write-sets.md index 6650fb6ba..68c521a56 100644 --- a/docs/jpa-cascades-vs-write-sets.md +++ b/docs/jpa-cascades-vs-write-sets.md @@ -177,7 +177,7 @@ This one inversion explains most of what follows: In JPA, persistence is a property of the object model. You configure once, on the mappings, how operations travel the graph. A persistence context keeps the loaded graph and the database converged, and the writes are derived from state: new managed entities, dirty fields, orphaned children, all collected and ordered at flush time. Cascades extend that convergence across associations. The call site stays small, `persist(owner)`, because the mapping and the session carry the knowledge. -In Storm, persistence is an operation on values. There is no session and no managed state; entities are plain values that describe rows. A write set is a single call whose inputs fully determine what happens: the verb, the members you pass, and the closure those values imply. The call site carries the knowledge, and the model stays free of behavior. +In Storm, persistence is an operation on values. There is no session and no managed state; entities are plain values that describe rows. A write set is a single call whose inputs fully determine what happens: the action, the members you pass, and the closure those values imply. The call site carries the knowledge, and the model stays free of behavior. Neither choice is free. The price of Storm's model is that every call site states its intent; there is no per-association default to configure once and rely on everywhere. The price of JPA's model is action at a distance: reading a call site is not enough to know what it writes, because the mapping and the current session state complete the sentence. @@ -219,9 +219,11 @@ Visit saved = orm.writeSet().insertAndFetch(visit); ### `merge` with MERGE becomes `update` or `upsert` -JPA's `merge` copies detached state onto a managed instance and, with `CascadeType.MERGE`, walks the associations; whether a row is inserted or updated follows from entity state. Storm splits that decision into two explicit verbs: `update` for rows you know exist, and [`upsert`](upserts.md) when the database should resolve it atomically. Both write exactly the explicit members. Referenced entities provide foreign key values but are never written implicitly, and `update` rejects unsaved members instead of quietly inserting them. +JPA's `merge` copies detached state onto a managed instance and, with `CascadeType.MERGE`, walks the associations; whether a row is inserted or updated follows from entity state. Storm splits that decision into two explicit actions: `update` for rows you know exist, and [`upsert`](upserts.md) when the database should resolve it atomically. Both write exactly the explicit members. Referenced entities provide foreign key values but are never written implicitly, and `update` rejects unsaved members instead of quietly inserting them. -This is the sharpest instinct to retrain. An updated `Owner` held inside a `Pet` passed to `update` is not written: a keyed referenced entity contributes only its primary key, so the owner's changes stay in memory. Where a JPA session would eventually flush a managed owner even without a cascade, Storm has no session and writes what you name, nothing else. When both changed, name both; each row is written with its own dirty check, so unchanged members still cost nothing: +This is the sharpest instinct to retrain. An updated `Owner` held inside a `Pet` passed to `update` is not written: a keyed referenced entity contributes only its primary key, so the owner's changes stay in memory. Where a JPA session would eventually flush a managed owner even without a cascade, Storm has no session and writes what you name, nothing else. + +There is no cascade that insert has and update lacks; one rule covers every action: a write set writes the entities you name, plus the entities your values make necessary. An unsaved referenced entity is necessary: the pet row cannot be written without the owner's key, and a row that does not exist cannot be a stale copy, so holding it is unambiguous intent to create it. A keyed referenced entity is never necessary: it already provides the foreign key value, and beyond that it is whatever snapshot was hydrated when the pet was read. If `update` wrote it anyway, every pet update would rewrite the owner row with a possibly stale copy, silently overwriting newer writes. JPA can afford to walk the graph on merge because the session identity-maps entities: the one managed owner genuinely represents current state plus your changes. Without a session, a snapshot is not write intent. When both changed, name both; each row is written with its own dirty check, so unchanged members still cost nothing: diff --git a/docs/write-sets.md b/docs/write-sets.md index f4b0fd7c8..92ad15e7b 100644 --- a/docs/write-sets.md +++ b/docs/write-sets.md @@ -7,7 +7,7 @@ Inserting an object graph that spans several tables normally requires careful ch A write set applies one write operation to a heterogeneous collection of entities. Storm partitions the entities by type, orders them by their foreign key dependencies, and writes them in dependency-ordered batches, propagating generated primary keys to dependent entities. `insert` and `upsert` extend the *explicit members* (the entities you supply) with *discovered members*: unsaved entities transitively reachable through insertable foreign key fields. This discovery is called the *insertion closure*. `update` and `remove` operate on the explicit members only. -Write sets are not a cascade in the JPA sense. There are no mapping annotations, no persistence context and no session-wide cascade: all writes derive from the entities supplied to the call and, for insert and upsert, their insertion closure. The per-row semantics of every verb are identical to the corresponding repository operation, including entity callbacks and dirty checking. Coming from JPA? [JPA Cascades vs Write Sets](jpa-cascades-vs-write-sets.md) compares the two models side by side. +Write sets are not a cascade in the JPA sense. There are no mapping annotations, no persistence context and no session-wide cascade: all writes derive from the entities supplied to the call and, for insert and upsert, their insertion closure. The per-row semantics of every action are identical to the corresponding repository operation, including entity callbacks and dirty checking. Coming from JPA? [JPA Cascades vs Write Sets](jpa-cascades-vs-write-sets.md) compares the two models side by side. A write set is obtained from the ORM template, or from any repository (`writeSet()` on a repository is a convenience that delegates to the underlying template; it is not scoped to the repository's entity type): @@ -17,7 +17,7 @@ A write set is obtained from the ORM template, or from any repository (`writeSet ```kotlin orm.writeSet().insert(entities) -// or scoped; each verb executes immediately, the block only groups the calls +// or scoped; each action executes immediately, the block only groups the calls transaction { orm.writeSet { insert(newEntities) @@ -208,9 +208,9 @@ The unsaved entity's default id in the key component is a placeholder; the write ## Update, Upsert and Remove -The remaining verbs follow the same contract, each with the ordering that suits it: +The remaining actions follow the same contract, each with the ordering that suits it: -- `update` groups the explicit members by type and updates them with the usual semantics, including transaction-scoped dirty checking: unchanged entities are skipped. Referenced entities are never updated implicitly. Unsaved members are rejected; a row that does not exist cannot be updated. +- `update` groups the explicit members by type and updates them with the usual semantics, including transaction-scoped dirty checking: unchanged entities are skipped. Referenced entities are never updated implicitly: an updated `Owner` held inside a `Pet` you update is not written, it contributes only its primary key. Pass both when both changed; dirty checking skips whichever members are unchanged. Unsaved members are rejected; a row that does not exist cannot be updated. - `upsert` applies the per-repository upsert semantics to the explicit members and inserts the discovered members of the insertion closure, with keys propagating as for insert. Explicit membership takes precedence: a keyed entity that is both supplied and referenced by another member is upserted, and is written before the members that reference it. Whether an upsert can create a row for a member carrying a preset key follows the dialect's per-repository upsert behavior. - `remove` deletes exactly the explicit members, children before parents. Members are correlated by entity type and primary key rather than by instance, so a member referencing another member is removed first regardless of which instance its foreign key field holds. Referenced entities are never removed implicitly. @@ -249,5 +249,5 @@ Write sets complete Storm's persistence story without introducing a session. Eac - **Save-or-update decisions** belong to [upserts](upserts.md), which resolve conflicts atomically in the database. - **Skipping unchanged rows and partial updates** belong to [dirty checking](dirty-checking.md), driven by the transaction-scoped entity cache. - **Cascading deletes of dependent rows** belong to the schema: declare `ON DELETE CASCADE` on the foreign key constraint. Storm does not discover children reactively. -- **Implicit updates of referenced entities** do not exist. A keyed entity referenced by a set member only provides its key; modifying it requires an explicit update. +- **Implicit updates of referenced entities** do not exist. A keyed entity referenced by a set member only provides its key; modifying it requires an explicit update. One rule covers every action: a write set writes the entities you name, plus the entities your values make necessary. An unsaved referenced entity is necessary (its dependent cannot be written without its key, and a row that does not exist cannot be a stale copy); a keyed referenced entity never is: it is the state that was hydrated when the value was read, and treating that snapshot as write intent would silently overwrite newer database state. - **Re-planning after entity callbacks** does not happen. Callbacks run inside the per-type repository operations, after members are discovered and the execution order is planned; a callback that alters foreign key fields does not change what is discovered or in which order it is written. diff --git a/storm-core/src/main/java/st/orm/core/repository/RepositoryLookup.java b/storm-core/src/main/java/st/orm/core/repository/RepositoryLookup.java index aaa5b1550..7ff63827c 100644 --- a/storm-core/src/main/java/st/orm/core/repository/RepositoryLookup.java +++ b/storm-core/src/main/java/st/orm/core/repository/RepositoryLookup.java @@ -82,7 +82,7 @@ public interface RepositoryLookup { /** * Returns dependency-aware write operations over mixed-type sets of entities. * - *

A write set lifts the per-repository write verbs to collections spanning multiple entity types: entities + *

A write set lifts the per-repository write actions to collections spanning multiple entity types: entities * are partitioned by type, ordered by their foreign key dependencies, and written with one batch statement per * type per dependency level. Generated primary keys propagate to dependent entities within the set.

* diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java index ac83f024b..f62b84bd3 100644 --- a/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java +++ b/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java @@ -67,7 +67,7 @@ public final class WriteSetImpl implements WriteSet { private static final ORMReflection REFLECTION = Providers.getORMReflection(); /** How the write set treats entities that are reachable but not part of the set. */ - private enum Verb { INSERT, UPSERT, UPDATE, REMOVE } + private enum Action { INSERT, UPSERT, UPDATE, REMOVE } private final RepositoryLookup lookup; private final ConcurrentMap, TypeInfo> typeInfoCache = new ConcurrentHashMap<>(); @@ -78,25 +78,25 @@ public WriteSetImpl(@Nonnull RepositoryLookup lookup) { @Override public void insert(@Nonnull Iterable> entities) { - executeOrdered(entities, Verb.INSERT, false); + executeOrdered(entities, Action.INSERT, false); } @Override @Nonnull public List> insertAndFetch(@Nonnull Iterable> entities) { - Execution execution = executeOrdered(entities, Verb.INSERT, true); + Execution execution = executeOrdered(entities, Action.INSERT, true); return fetch(execution); } @Override public void upsert(@Nonnull Iterable> entities) { - executeOrdered(entities, Verb.UPSERT, false); + executeOrdered(entities, Action.UPSERT, false); } @Override @Nonnull public List> upsertAndFetch(@Nonnull Iterable> entities) { - Execution execution = executeOrdered(entities, Verb.UPSERT, true); + Execution execution = executeOrdered(entities, Action.UPSERT, true); return fetch(execution); } @@ -148,7 +148,7 @@ static final class Node { /** An unsaved entity referenced through an FK component, together with the component to rebuild. */ private record Dependency(FkEdge edge, Object target) {} - private Execution executeOrdered(@Nonnull Iterable> entities, @Nonnull Verb verb, + private Execution executeOrdered(@Nonnull Iterable> entities, @Nonnull Action action, boolean fetchKeys) { List inputs = new ArrayList<>(); entities.forEach(inputs::add); @@ -172,7 +172,7 @@ private Execution executeOrdered(@Nonnull Iterable> entities Node node = queue.poll(); TypeInfo info = typeInfo(node.entity.getClass()); for (FkEdge edge : info.fkEdges) { - Object target = resolveTarget(node.entity, edge, verb); + Object target = resolveTarget(node.entity, edge, action); if (target == null || !isUnsaved(target)) { continue; } @@ -204,7 +204,7 @@ private Execution executeOrdered(@Nonnull Iterable> entities Map keyedMembers = new HashMap<>(); for (Node node : discoveryOrder) { TypeInfo info = typeInfo(node.entity.getClass()); - boolean keyPreserved = !info.autoGeneratedPrimaryKey || (verb == Verb.UPSERT && node.passed); + boolean keyPreserved = !info.autoGeneratedPrimaryKey || (action == Action.UPSERT && node.passed); if (keyPreserved && node.dependencies.stream().noneMatch(dependency -> writesPrimaryKey(info, dependency.edge()))) { Object id = ((Entity) node.entity).id(); @@ -239,19 +239,19 @@ private Execution executeOrdered(@Nonnull Iterable> entities } } // Execute level by level, batched per type. Discovered members are always inserted; explicit members are - // inserted or upserted according to the verb. + // inserted or upserted according to the action. IdentityHashMap persistedView = new IdentityHashMap<>(); for (Map, List> byType : groupByLevelAndType(discoveryOrder)) { for (var entry : byType.entrySet()) { TypeInfo info = typeInfo(entry.getKey()); - if (verb == Verb.UPSERT) { + if (action == Action.UPSERT) { // Discovered members are inserted; only explicit members carry upsert semantics. persistBatch(info, entry.getValue().stream().filter(node -> !node.passed).toList(), - persistedView, Verb.INSERT, fetchKeys, keyConsumers); + persistedView, Action.INSERT, fetchKeys, keyConsumers); persistBatch(info, entry.getValue().stream().filter(node -> node.passed).toList(), - persistedView, Verb.UPSERT, fetchKeys, keyConsumers); + persistedView, Action.UPSERT, fetchKeys, keyConsumers); } else { - persistBatch(info, entry.getValue(), persistedView, Verb.INSERT, fetchKeys, keyConsumers); + persistBatch(info, entry.getValue(), persistedView, Action.INSERT, fetchKeys, keyConsumers); } } } @@ -275,7 +275,7 @@ private static List, List>> groupByLevelAndType(@Nonnull List private void persistBatch(@Nonnull TypeInfo info, @Nonnull List nodes, @Nonnull IdentityHashMap persistedView, - @Nonnull Verb verb, + @Nonnull Action action, boolean fetchKeys, @Nonnull IdentityHashMap keyConsumers) { if (nodes.isEmpty()) { @@ -291,14 +291,14 @@ private void persistBatch(@Nonnull TypeInfo info, if (keysNeeded) { // The fetch-ids operations return the ids in input order; the same positional contract is relied upon // by the repository implementations themselves (see JoinedEntityHelper). - List ids = verb == Verb.UPSERT + List ids = action == Action.UPSERT ? repository.upsertAndFetchIds(prepared) : repository.insertAndFetchIds(prepared); for (int i = 0; i < nodes.size(); i++) { persistedView.put(nodes.get(i).entity, withPrimaryKey(info, prepared.get(i), ids.get(i))); } } else { - if (verb == Verb.UPSERT) { + if (action == Action.UPSERT) { repository.upsert(prepared); } else { repository.insert(prepared); @@ -496,7 +496,7 @@ static void assignLevels(@Nonnull IdentityHashMap nodes, @Nonnull * for insert and upsert. */ @Nullable - private Object resolveTarget(@Nonnull Object entity, @Nonnull FkEdge edge, @Nonnull Verb verb) { + private Object resolveTarget(@Nonnull Object entity, @Nonnull FkEdge edge, @Nonnull Action action) { Object value = valueAt(entity, edge.path); if (value == null) { return null; @@ -507,7 +507,7 @@ private Object resolveTarget(@Nonnull Object entity, @Nonnull FkEdge edge, @Nonn if (wrapped != null) { return wrapped; } - if ((verb == Verb.INSERT || verb == Verb.UPSERT) && REFLECTION.isDefaultValue(ref.id())) { + if ((action == Action.INSERT || action == Action.UPSERT) && REFLECTION.isDefaultValue(ref.id())) { throw new PersistenceException(("Foreign key component '%s.%s' holds an id-only Ref with a default " + "id. An id-only Ref cannot describe a new %s; wrap the instance instead: Ref.of(entity).") .formatted(entity.getClass().getSimpleName(), edge.name, diff --git a/storm-foundation/src/main/java/st/orm/WriteSet.java b/storm-foundation/src/main/java/st/orm/WriteSet.java index 8defc4a0f..9ef26d6af 100644 --- a/storm-foundation/src/main/java/st/orm/WriteSet.java +++ b/storm-foundation/src/main/java/st/orm/WriteSet.java @@ -25,7 +25,7 @@ * and {@link #upsert(Iterable)} extend the explicit members (the entities supplied by the caller) with * discovered members: unsaved entities transitively reachable through insertable, entity-valued foreign key * fields (the insertion closure). {@link #update(Iterable)} and {@link #remove(Iterable)} operate on the - * explicit members only. The per-row semantics of each verb are identical to the corresponding + * explicit members only. The per-row semantics of each action are identical to the corresponding * {@code EntityRepository} operation; the write set adds partitioning by type, dependency ordering and generated-key * propagation:

* @@ -79,6 +79,14 @@ * be executed by the dependency-ordering strategy (the write set does not break cycles using nullable intermediate * values, deferred constraints or follow-up updates).

* + *

Note on modified referenced entities: a keyed entity held in a foreign key field contributes + * exactly its primary key; modifications to it are not persisted by writing its dependent, by any action. One rule + * covers every action: a write set writes the entities named by the caller, plus the entities the values make + * necessary. An unsaved referenced entity is necessary (its dependent cannot be written without its key, and a row + * that does not exist cannot be a stale copy); a keyed referenced entity never is: it is the state that was hydrated + * when the value was read, and treating that snapshot as write intent would silently overwrite newer database state. + * To persist changes to a referenced entity, pass it as an explicit member.

+ * *

Note on unsaved refs: {@code Ref} equality is based on type and id. Two refs wrapping distinct * unsaved instances therefore compare equal until the instances are persisted. Do not use unsaved refs as map keys or * set members; the write set itself correlates by instance identity and is not affected.

@@ -131,6 +139,11 @@ public interface WriteSet { * unsaved explicit member is rejected (a row that does not exist cannot be updated), and an unsaved referenced * entity fails where its key is required as a foreign key value.

* + *

In particular, a modified referenced entity is not written: a keyed entity held in a foreign key field of a + * member contributes only its primary key, so its changes stay in memory. To persist changes to both a member and + * an entity it references, pass both as explicit members; dirty checking skips whichever members are + * unchanged.

+ * * @param entities the entities to update; may span multiple entity types. * @throws PersistenceException if an explicit member or a referenced entity is unsaved, or if the update fails. */ @@ -154,7 +167,9 @@ public interface WriteSet { * {@code ON CONFLICT} / {@code MERGE} where available); explicit membership takes precedence, so a keyed entity * that is both supplied and referenced by another member is upserted, and is written before its dependents. * Unsaved entities reachable through insertable foreign key fields join the set as discovered members and are - * inserted before their dependents, with generated keys propagated by instance identity.

+ * inserted before their dependents, with generated keys propagated by instance identity. Keyed + * referenced entities that are not explicit members only provide foreign key values; modifications to them are + * not persisted.

* * @param entities the entities to upsert; may span multiple entity types. * @throws PersistenceException if the dependencies contain a cycle that cannot be ordered, if an unsaved entity diff --git a/storm-java21/src/main/java/st/orm/repository/RepositoryLookup.java b/storm-java21/src/main/java/st/orm/repository/RepositoryLookup.java index 6fd7d5cec..a53e575ce 100644 --- a/storm-java21/src/main/java/st/orm/repository/RepositoryLookup.java +++ b/storm-java21/src/main/java/st/orm/repository/RepositoryLookup.java @@ -81,7 +81,7 @@ public interface RepositoryLookup { /** * Returns dependency-aware write operations over mixed-type sets of entities. * - *

A write set lifts the per-repository write verbs to collections spanning multiple entity types: entities + *

A write set lifts the per-repository write actions to collections spanning multiple entity types: entities * are partitioned by type, ordered by their foreign key dependencies, and written with one batch statement per * type per dependency level. Generated primary keys propagate to dependent entities within the set.

* diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt index 3c1af87c1..a70e32ea7 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt @@ -89,7 +89,7 @@ interface RepositoryLookup { /** * Returns dependency-aware write operations over mixed-type sets of entities. * - * A write set lifts the per-repository write verbs to collections spanning multiple entity types: entities + * A write set lifts the per-repository write actions to collections spanning multiple entity types: entities * are partitioned by type, ordered by their foreign key dependencies, and written with one batch statement per * type per dependency level. Generated primary keys propagate to dependent entities within the set. * @@ -112,7 +112,7 @@ interface RepositoryLookup { /** * Runs [block] against this lookup's [WriteSet], grouping related write-set calls in one scope. * - * Each verb inside the block executes immediately; the block groups calls visually and does not defer or reorder + * Each action inside the block executes immediately; the block groups calls visually and does not defer or reorder * them. Combine with `transaction { }` when atomicity across the calls is required: * * ```kotlin @@ -132,7 +132,7 @@ inline fun RepositoryLookup.writeSet(block: WriteSet.() -> R): R = writeSet( /** * Runs [block] against the underlying ORM template's [WriteSet]; see [RepositoryLookup.writeSet]. * - * Convenience for repository methods; each verb inside the block executes immediately. + * Convenience for repository methods; each action inside the block executes immediately. * * @since 1.13 */ diff --git a/website/src/pages/tutorials/object-graphs.js b/website/src/pages/tutorials/object-graphs.js index 3decf1bdf..2be4ff6b5 100644 --- a/website/src/pages/tutorials/object-graphs.js +++ b/website/src/pages/tutorials/object-graphs.js @@ -132,8 +132,9 @@ ${navHtml('tutorials')}
A write set executes several statements and is not atomic by itself. Wrap it in transaction { } when the graph must commit or fail as one. Keys correlate by instance, not by equality: a copy() of an unsaved parent describes a second row.

05Updates, deletes, and orphanRemoval

-

The other verbs follow the same contract. Where merge with CascadeType.MERGE walks the associations and decides insert-or-update from entity state, Storm splits the decision into explicit verbs: update for rows you know exist, upsert when the database should resolve it atomically. Both write exactly the entities you pass, and update rejects unsaved members instead of quietly inserting them.

+

The other actions follow the same contract. Where merge with CascadeType.MERGE walks the associations and decides insert-or-update from entity state, Storm splits the decision into explicit actions: update for rows you know exist, upsert when the database should resolve it atomically. Both write exactly the entities you pass, and update rejects unsaved members instead of quietly inserting them.

That includes the case that trips JPA instincts hardest: an updated owner held inside a pet you pass to update is not written. A keyed referenced entity contributes only its primary key, and there is no session that will flush the owner later. When both changed, name both: update(pet, owner). Each row is written with its own dirty check, so unchanged members still cost nothing.

+

There is no cascade that insert has and update lacks; one rule covers every action: a write set writes the entities you name, plus the entities your values make necessary. An unsaved owner is necessary, the pet row cannot be written without its key, so holding it is unambiguous intent to create it. A keyed owner is never necessary: it already provides the foreign key value, and beyond that it is whatever snapshot was hydrated when the pet was read. Writing it anyway would silently overwrite newer state with a stale copy. JPA can afford to walk the graph on merge because the session identity-maps entities; without a session, a snapshot is not write intent.

remove deletes the entities you name, children before parents. What it does not do is walk the graph looking for dependents:

${editor({file: 'CleanupService.kt', tag: 'Kotlin · Storm', code: CODE_STORM_REMOVE})}

orphanRemoval has no counterpart by design: Storm entities hold no child collections, so there is no collection mutation to react to. Declare the reaction where the database can enforce it for every writer, on the constraint, or delete rows explicitly.

From 9e736d464c59897236a2b83c82703c57d9ad349c Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Wed, 15 Jul 2026 09:18:24 +0200 Subject: [PATCH 4/4] docs(website): clone bar with GitHub button on the benchmarks page Replace the inline reproduce-it sentence with the clone bar and "View on GitHub" button used on the example pages. --- website/src/pages/benchmarks.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/website/src/pages/benchmarks.js b/website/src/pages/benchmarks.js index 7839a728b..d4eaa5c74 100644 --- a/website/src/pages/benchmarks.js +++ b/website/src/pages/benchmarks.js @@ -375,6 +375,10 @@ const BM_CSS = ` .storm-tut .art h3{margin:44px 0 10px} .storm-tut .art h3 + p{margin-top:0} .bm-stat b{background:linear-gradient(100deg,#feeeb0,#fbbf24 55%,#f59e0b);-webkit-background-clip:text;background-clip:text;color:transparent} + .storm-tut .getit{display:flex;gap:12px;margin-top:26px;flex-wrap:wrap;align-items:stretch} + .storm-tut .clonebar{display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:13px;color:var(--plain); + background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:0 18px;min-height:44px;overflow-x:auto;white-space:nowrap} + .storm-tut .clonebar .dollar{color:var(--green);user-select:none} .bm-matrix-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:14px;background:var(--panel);margin:22px 0 10px;padding:10px 12px} .art .bm-matrix,.art .bm-matrix thead,.art .bm-matrix tbody{background:none} .art .bm-matrix{width:100%;border-collapse:separate;border-spacing:3px;font-family:var(--mono);font-size:12px;margin:0} @@ -480,7 +484,11 @@ ${charts}
  • Rows are the unit of comparison. Libraries within one chart ran in the same session under the same conditions. Comparing across charts, or treating values as absolute costs, carries environment drift that comparing within a chart does not.
  • Versions: Storm 1.13.0, Hibernate 7.4.5, jOOQ 3.21.6, Exposed 1.3.1, Jimmer 0.11.0, PostgreSQL 17, JDK 21.

    -

    Reproduce it: git clone https://github.com/storm-orm/storm-benchmarks && scripts/run.sh. The repository contains the full methodology, the statement-log auditing tools used to verify round-trip counts, and every implementation in full.

    +

    The repository contains the full methodology, the statement-log auditing tools used to verify round-trip counts, and every implementation in full; scripts/run.sh reproduces the numbers.

    +
    +
    $git clone https://github.com/storm-orm/storm-benchmarks.git
    + View on GitHub → +
    ${FOOT_HTML}