From 04c14d911af93fd44c88883e8c2d64c99fc526b1 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort
Date: Wed, 15 Jul 2026 00:27:16 +0200
Subject: [PATCH 1/3] feat: dependency-aware write sets for mixed-type entity
graphs
Add WriteSet, obtained via writeSet() on ORMTemplate and repositories:
insert/update/upsert/remove plus AndFetch variants over collections that
may span multiple entity types. Insert and upsert extend the explicit
members with their insertion closure (unsaved entities transitively
reachable through insertable foreign key fields, including inline
components and entity-wrapped refs). Execution is ordered by foreign key
dependencies, batched per type per dependency level, and generated keys
propagate to dependents by instance identity via record reconstruction.
Remove deletes children before parents, correlating members by type and
primary key. Generated keys are only fetched when a dependent or the
caller consumes them.
Ref.of(entity) now accepts unsaved entities so refs can carry graph
edges. The unsaved-foreign-key error message points to writeSet().
Kotlin adds a scoped writeSet { } block; single-root typed variants are
default methods on the shared interface.
Fixes #269
---
README.md | 1 +
docs/comparison.md | 5 +-
docs/write-sets.md | 189 +++
.../st/orm/core/repository/Repository.java | 16 +
.../orm/core/repository/RepositoryLookup.java | 17 +
.../core/repository/impl/WriteSetImpl.java | 702 ++++++++++
.../st/orm/core/template/impl/ModelImpl.java | 3 +-
.../st/orm/core/WriteSetIntegrationTest.java | 376 ++++++
.../repository/impl/WriteSetLevelingTest.java | 57 +
.../src/main/java/st/orm/Ref.java | 7 +-
.../src/main/java/st/orm/WriteSet.java | 274 ++++
.../java/st/orm/spi/h2/H2WriteSetTest.java | 142 ++
.../java/st/orm/repository/Repository.java | 16 +
.../st/orm/repository/RepositoryLookup.java | 14 +
.../st/orm/template/impl/ORMTemplateImpl.java | 6 +
.../kotlin/st/orm/repository/Repository.kt | 14 +
.../st/orm/repository/RepositoryLookup.kt | 49 +
.../st/orm/template/impl/ORMTemplateImpl.kt | 3 +
.../kotlin/st/orm/template/WriteSetTest.kt | 114 ++
website/scripts/generate-llms-full.sh | 1 +
website/sidebars.ts | 1 +
website/static/llms-full.txt | 1177 +++++++++++++----
.../static/skills/storm-repository-java.md | 28 +
.../static/skills/storm-repository-kotlin.md | 33 +
24 files changed, 2994 insertions(+), 251 deletions(-)
create mode 100644 docs/write-sets.md
create mode 100644 storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java
create mode 100644 storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
create mode 100644 storm-core/src/test/java/st/orm/core/repository/impl/WriteSetLevelingTest.java
create mode 100644 storm-foundation/src/main/java/st/orm/WriteSet.java
create mode 100644 storm-h2/src/test/java/st/orm/spi/h2/H2WriteSetTest.java
create mode 100644 storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt
diff --git a/README.md b/README.md
index 3c2cec8b2..5e0933d93 100644
--- a/README.md
+++ b/README.md
@@ -203,6 +203,7 @@ Everything you need to build applications with Storm. Start with Getting Started
| [Refs](docs/refs.md) | Lazy loading and optimized references (7 min) |
| [Batch & Streaming](docs/batch-streaming.md) | Bulk operations and Flow/Stream (5 min) |
| [Upserts](docs/upserts.md) | Insert-or-update operations (6 min) |
+| [Write Sets](docs/write-sets.md) | Dependency-ordered writes of mixed-type entity graphs (7 min) |
| [Polymorphism](docs/polymorphism.md) | Sealed type inheritance strategies (20 min) |
| [Entity Lifecycle](docs/entity-lifecycle.md) | Callbacks for auditing, validation, and logging (8 min) |
| [JSON Support](docs/json.md) | JSON columns and aggregation (6 min) |
diff --git a/docs/comparison.md b/docs/comparison.md
index 2e01bdc5f..30d751222 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -18,7 +18,7 @@ The following tables provide a side-by-side comparison of concrete features acro
| 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 |
+| Cascade persist | Write sets10 | Yes | Yes | No | No | No | Yes | No | No |
| Lifecycle callbacks | Yes | Yes | Via JPA | No | Yes | No | Yes | DAO only | No |
1 JPA/Spring Data lines without Lombok; ~10 lines with Lombok.
@@ -29,6 +29,8 @@ The following tables provide a side-by-side comparison of concrete features acro
8 Jimmer supports `@MappedSuperclass` for sharing fields across entities, but not JPA-style single-table, joined, or table-per-class inheritance strategies.
+10 Storm persists entity graphs through [write sets](write-sets.md): dependency-ordered, mixed-type batch writes with generated-key propagation, declared explicitly at the call site (`orm.writeSet()`) rather than configured on the mapping.
+
### Querying & Data Access
| Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Jimmer | Exposed | Ktorm |
@@ -77,6 +79,7 @@ JPA (typically implemented by Hibernate) is the most widely used persistence fra
| **Polymorphism** | Sealed types (Single-Table, Joined, Polymorphic FK); STRING, INTEGER, CHAR discriminators | Class hierarchy (Single-Table, Joined, Table-per-Class); STRING, INTEGER, CHAR discriminators |
| **State** | Stateless; no persistence context | Managed entities |
| **Loading** | Loading in single query | Lazy loading common |
+| **Graph persistence** | Write sets: explicit, dependency-ordered, call-local | Cascade annotations on mappings |
| **N+1 Problem** | Prevented by design; requires explicit opt-in | Common pitfall |
| **Queries** | Type-safe DSL, SQL Templates | JPQL, Criteria API |
| **Caching** | Transaction-scoped observation | First/second level cache |
diff --git a/docs/write-sets.md b/docs/write-sets.md
new file mode 100644
index 000000000..97e788a5e
--- /dev/null
+++ b/docs/write-sets.md
@@ -0,0 +1,189 @@
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+# Write Sets
+
+Inserting an object graph that spans several tables normally requires careful choreography: insert the parents first, collect their generated keys, rebuild the children against those keys, and repeat for every level. Write sets lift that burden.
+
+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.
+
+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):
+
+
+
+
+```kotlin
+orm.writeSet().insert(entities)
+
+// or scoped; each verb executes immediately, the block only groups the calls
+transaction {
+ orm.writeSet {
+ insert(newEntities)
+ update(changedEntities)
+ remove(staleEntities)
+ }
+}
+```
+
+
+
+
+```java
+orm.writeSet().insert(entities);
+```
+
+
+
+
+---
+
+## Inserting a Graph
+
+Consider the classic schema where a `Pet` references an `Owner` and a `Visit` references a `Pet`. With a write set, the whole graph is built in memory first, linking children to their parents by holding the parent instance, and inserted in one call:
+
+
+
+
+```kotlin
+val owner = Owner(firstName = "Alice", lastName = "Bond", address = address) // unsaved
+val wolfie = Pet(name = "Wolfie", birthDate = birthDate, type = dog, owner = owner)
+val rex = Pet(name = "Rex", birthDate = birthDate, type = dog, owner = owner) // same owner instance
+val visit = Visit(visitDate = today, description = "Check-up", pet = wolfie)
+
+orm.writeSet().insert(listOf(wolfie, rex, visit))
+```
+
+
+
+
+```java
+var owner = new Owner("Alice", "Bond", address); // unsaved
+var wolfie = new Pet("Wolfie", birthDate, dog, owner);
+var rex = new Pet("Rex", birthDate, dog, owner); // same owner instance
+var visit = new Visit(today, "Check-up", wolfie);
+
+orm.writeSet().insert(List.of(wolfie, rex, visit));
+```
+
+
+
+
+This particular graph needs three dependency-ordered batch operations, regardless of how many pets or visits the set contains: owners first, then pets, then visits. The owner was never passed explicitly; it becomes a discovered member because the pet values hold it in their foreign key field. That is the insertion closure at work: a record whose foreign key field holds an unsaved entity is a value that describes both rows, and inserting the value inserts both.
+
+The rules, in full:
+
+1. **Insert and upsert write the explicit members plus their insertion closure.** Discovery follows insertable, entity-valued foreign key fields (including fields inside inline components) and entity-wrapped refs, and picks up unsaved entities transitively. Referenced entities that already carry a primary key are never discovered; unless they are explicit members themselves, they only provide foreign key values.
+2. **Update and remove write exactly the explicit members.** Referenced entities are never updated or removed implicitly.
+3. **Storm determines a valid execution order** from the foreign key dependencies: parents before children for insert and upsert, children before parents for remove.
+4. **Generated keys propagate by instance identity.** Children link to a new parent by holding the same instance: one unsaved instance describes one prospective row, and two structurally equal but distinct unsaved instances describe two separate rows.
+5. **Execution is grouped into one batch operation per entity type per dependency level** (large batches are split by the configured batch size). The batch count follows the dependency shape of the data: a self-referencing type whose rows span several dependency levels needs one batch per level.
+
+An entity counts as *unsaved* when its primary key is the default value and the key is auto-generated. The test is local and deterministic; no session state or database round trip is involved.
+
+A write set executes multiple statements and is not atomic by itself: if a later dependency level fails, earlier levels have already been written. Run write sets inside a transaction when atomicity across the set is required.
+
+:::warning Instance identity, not equality
+
+Key propagation correlates by instance, not by `equals`. A `copy()` of an unsaved parent is a different row. Link children to the exact instance you want them to share.
+
+:::
+
+## Fetching the Result
+
+`insertAndFetch` returns the passed entities as they exist in the database after the write, in input order, with generated keys, defaults and version columns reflected and referenced parents hydrated. Single-root variants accept one entity and return it typed, so the common "insert this graph, give me the keyed result" is one call:
+
+
+
+
+```kotlin
+val inserted = orm.writeSet().insertAndFetch(listOf(wolfie, visit))
+val fetchedPet = inserted[0] as Pet // fetchedPet.owner carries the generated key
+
+val fetchedVisit: Visit = orm.writeSet().insertAndFetch(visit) // single root, typed
+```
+
+
+
+
+```java
+var inserted = orm.writeSet().insertAndFetch(List.of(wolfie, visit));
+var fetchedPet = (Pet) inserted.get(0); // fetchedPet.owner() carries the generated key
+
+Visit fetchedVisit = orm.writeSet().insertAndFetch(visit); // single root, typed
+```
+
+
+
+
+## Refs
+
+Foreign key fields typed as `Ref` participate through entity-wrapped refs, which carry the instance:
+
+
+
+
+```kotlin
+val pet = Pet(name = "Shadow", birthDate = birthDate, type = dog, owner = Ref.of(owner))
+orm.writeSet().insert(listOf(pet)) // owner is inserted first, the ref binds the generated key
+```
+
+
+
+
+```java
+var pet = new Pet("Shadow", birthDate, dog, Ref.of(owner));
+orm.writeSet().insert(List.of(pet)); // owner is inserted first, the ref binds the generated key
+```
+
+
+
+
+Id-only refs such as `Ref.of(Owner::class, 42)` are pointers to known rows: they bind as foreign key values and are never written. An id-only ref carrying a default id cannot describe a new entity and fails fast. Note that ref equality is based on type and id, so refs wrapping distinct unsaved instances compare equal until the instances are persisted; do not use unsaved refs as map keys.
+
+## Update, Upsert and Remove
+
+The remaining verbs 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.
+- `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.
+
+
+
+
+```kotlin
+orm.writeSet().remove(listOf(owner, pet, visit)) // executed as: visit, pet, owner
+```
+
+
+
+
+```java
+orm.writeSet().remove(List.of(owner, pet, visit)); // executed as: visit, pet, owner
+```
+
+
+
+
+## Fail-Fast Behavior
+
+Write sets keep Storm's fail-fast doctrine. Each of the following raises a descriptive `PersistenceException` before any statement is executed:
+
+- A dependency cycle that cannot be executed by the dependency-ordering strategy. The write set does not break cycles using nullable intermediate values, deferred constraints, or follow-up updates.
+- An id-only ref with a default id in an insert or upsert set (it cannot describe a new entity; wrap the instance instead).
+- An unsaved entity passed to update or remove.
+- An unsaved entity referenced through a non-insertable foreign key component (typically a junction table whose key columns live inside a composite primary key).
+
+References to entities outside the effective write set follow the normal repository rules: keyed references only provide foreign key values and are never written; unsaved references fail wherever their key is required as a foreign key value.
+
+## What Write Sets Do Not Do
+
+Write sets complete Storm's persistence story without introducing a session. Each concern has a dedicated tool:
+
+- **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.
+- **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/Repository.java b/storm-core/src/main/java/st/orm/core/repository/Repository.java
index e269f8cc6..1c5f1f589 100644
--- a/storm-core/src/main/java/st/orm/core/repository/Repository.java
+++ b/storm-core/src/main/java/st/orm/core/repository/Repository.java
@@ -15,6 +15,7 @@
*/
package st.orm.core.repository;
+import st.orm.WriteSet;
import st.orm.core.template.ORMTemplate;
/**
@@ -28,4 +29,19 @@ public interface Repository {
* @return the ORM template.
*/
ORMTemplate orm();
+
+ /**
+ * Returns dependency-aware write operations over mixed-type sets of entities.
+ *
+ * The write set belongs to the underlying ORM template and is not scoped to this repository's entity type;
+ * it accepts entities of any type. This accessor is a convenience for repository methods, equivalent to
+ * {@code orm().writeSet()}.
+ *
+ * @return the write set operations of the underlying ORM template.
+ * @see WriteSet
+ * @since 1.13
+ */
+ default WriteSet writeSet() {
+ return orm().writeSet();
+ }
}
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 7c10db496..aaa5b1550 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
@@ -18,6 +18,8 @@
import jakarta.annotation.Nonnull;
import st.orm.Entity;
import st.orm.Projection;
+import st.orm.WriteSet;
+import st.orm.core.repository.impl.WriteSetImpl;
/**
* Provides access to repositories.
@@ -76,4 +78,19 @@ public interface RepositoryLookup {
* @return a proxy for the repository of the given type.
*/
R repository(@Nonnull Class type);
+
+ /**
+ * 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
+ * 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.
+ *
+ * @return the write set operations bound to this lookup.
+ * @see WriteSet
+ * @since 1.13
+ */
+ default WriteSet writeSet() {
+ return new WriteSetImpl(this);
+ }
}
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
new file mode 100644
index 000000000..2e5876bf9
--- /dev/null
+++ b/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java
@@ -0,0 +1,702 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm.core.repository.impl;
+
+import static java.util.Objects.requireNonNull;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.lang.reflect.ParameterizedType;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import st.orm.Data;
+import st.orm.Entity;
+import st.orm.FK;
+import st.orm.GenerationStrategy;
+import st.orm.PK;
+import st.orm.Persist;
+import st.orm.PersistenceException;
+import st.orm.Ref;
+import st.orm.WriteSet;
+import st.orm.core.repository.EntityRepository;
+import st.orm.core.repository.RepositoryLookup;
+import st.orm.core.spi.ORMReflection;
+import st.orm.core.spi.Providers;
+import st.orm.core.template.Column;
+import st.orm.core.template.Model;
+import st.orm.mapping.RecordField;
+import st.orm.mapping.RecordType;
+
+/**
+ * Default implementation of {@link WriteSet}.
+ *
+ * Resolves the dependency graph of the passed entities, partitions it into topological levels, and delegates each
+ * level to the existing per-type repository batch operations. Generated primary keys are propagated to dependent
+ * records by rebuilding the immutable records with the keyed instances, correlated by instance identity.
+ *
+ * Instances are stateless per call (all resolution state is method-local; the per-type metadata cache is
+ * concurrent) and can safely be shared across threads.
+ *
+ * @since 1.13
+ */
+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 final RepositoryLookup lookup;
+ private final ConcurrentMap, TypeInfo> typeInfoCache = new ConcurrentHashMap<>();
+
+ public WriteSetImpl(@Nonnull RepositoryLookup lookup) {
+ this.lookup = requireNonNull(lookup, "lookup");
+ }
+
+ @Override
+ public void insert(@Nonnull Iterable extends Entity>> entities) {
+ executeOrdered(entities, Verb.INSERT, false);
+ }
+
+ @Override
+ @Nonnull
+ public List> insertAndFetch(@Nonnull Iterable extends Entity>> entities) {
+ Execution execution = executeOrdered(entities, Verb.INSERT, true);
+ return fetch(execution);
+ }
+
+ @Override
+ public void upsert(@Nonnull Iterable extends Entity>> entities) {
+ executeOrdered(entities, Verb.UPSERT, false);
+ }
+
+ @Override
+ @Nonnull
+ public List> upsertAndFetch(@Nonnull Iterable extends Entity>> entities) {
+ Execution execution = executeOrdered(entities, Verb.UPSERT, true);
+ return fetch(execution);
+ }
+
+ @Override
+ public void update(@Nonnull Iterable extends Entity>> entities) {
+ executeUpdate(entities);
+ }
+
+ @Override
+ @Nonnull
+ public List> updateAndFetch(@Nonnull Iterable extends Entity>> entities) {
+ Execution execution = executeUpdate(entities);
+ return fetch(execution);
+ }
+
+ @Override
+ public void remove(@Nonnull Iterable extends Entity>> entities) {
+ executeRemove(entities);
+ }
+
+ //
+ // Graph resolution and execution for insert / upsert.
+ //
+
+ /**
+ * The outcome of an executed write set: the passed entities in input order and the persisted view of every node,
+ * keyed by instance identity, for id resolution during fetch.
+ */
+ private record Execution(List inputs, IdentityHashMap persistedView) {}
+
+ /**
+ * A node of the resolved graph: the entity, whether it was passed explicitly, its unsaved dependencies (which
+ * require key propagation) and its ordering-only dependencies on other set members (keyed rows that must be
+ * written first, correlated by primary key).
+ */
+ static final class Node {
+ final Object entity;
+ boolean passed;
+ final List dependencies = new ArrayList<>(4);
+ final List orderingDependencies = new ArrayList<>(2);
+ int level = -1;
+
+ Node(Object entity, boolean passed) {
+ this.entity = entity;
+ this.passed = passed;
+ }
+ }
+
+ /** 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 extends Entity>> entities, @Nonnull Verb verb,
+ boolean fetchKeys) {
+ List inputs = new ArrayList<>();
+ entities.forEach(inputs::add);
+ // Discover the insertion closure: explicit members plus unsaved entities transitively reachable through
+ // insertable foreign key fields.
+ IdentityHashMap nodes = new IdentityHashMap<>();
+ List discoveryOrder = new ArrayList<>();
+ ArrayDeque queue = new ArrayDeque<>();
+ for (Object input : inputs) {
+ Node node = nodes.get(input);
+ if (node == null) {
+ node = new Node(input, true);
+ nodes.put(input, node);
+ discoveryOrder.add(node);
+ queue.add(node);
+ } else {
+ node.passed = true;
+ }
+ }
+ while (!queue.isEmpty()) {
+ Node node = queue.poll();
+ TypeInfo info = typeInfo(node.entity.getClass());
+ for (FkEdge edge : info.fkEdges) {
+ Object target = resolveTarget(node.entity, edge, verb);
+ if (target == null || !isUnsaved(target)) {
+ continue;
+ }
+ if (!edge.insertable) {
+ throw new PersistenceException(("Foreign key component '%s.%s' is not insertable but references " +
+ "an unsaved %s. Its column value is persisted elsewhere (typically inside the primary " +
+ "key), so the write set cannot propagate the generated key. Persist the %s first and set " +
+ "its id explicitly.").formatted(
+ node.entity.getClass().getSimpleName(), edge.name,
+ target.getClass().getSimpleName(), target.getClass().getSimpleName()));
+ }
+ Node targetNode = nodes.get(target);
+ if (targetNode == null) {
+ targetNode = new Node(target, false);
+ nodes.put(target, targetNode);
+ discoveryOrder.add(targetNode);
+ queue.add(targetNode);
+ }
+ node.dependencies.add(new Dependency(edge, target));
+ }
+ }
+ // Members with a preserved primary key may be referenced by other members through their key rather than by
+ // instance. Such references carry no key propagation, but the referenced row must be written first to
+ // satisfy foreign key constraints. A key is preserved when it is not generated, or when the member receives
+ // upsert semantics: an upsert matches on the provided key instead of generating a new one, so an explicitly
+ // passed keyed member of an upsert set is orderable even when its key column is auto-generated.
+ Map keyedMembers = new HashMap<>();
+ for (Node node : discoveryOrder) {
+ TypeInfo info = typeInfo(node.entity.getClass());
+ boolean keyPreserved = !info.autoGeneratedPrimaryKey || (verb == Verb.UPSERT && node.passed);
+ if (keyPreserved) {
+ Object id = ((Entity>) node.entity).id();
+ if (!REFLECTION.isDefaultValue(id)) {
+ keyedMembers.put(new TypeIdKey(node.entity.getClass(), id), node);
+ }
+ }
+ }
+ if (!keyedMembers.isEmpty()) {
+ for (Node node : discoveryOrder) {
+ TypeInfo info = typeInfo(node.entity.getClass());
+ for (FkEdge edge : info.fkEdges) {
+ Object targetId = resolveTargetId(node.entity, edge);
+ if (targetId == null || REFLECTION.isDefaultValue(targetId)) {
+ continue;
+ }
+ Node keyedMember = keyedMembers.get(new TypeIdKey(edge.targetType, targetId));
+ if (keyedMember != null && keyedMember != node) {
+ node.orderingDependencies.add(keyedMember);
+ }
+ }
+ }
+ }
+ assignLevels(nodes, discoveryOrder);
+ // A node's generated key is only fetched when something consumes it: a dependent node (key propagation) or
+ // the caller (AndFetch). Everything else is written without fetch mode, keeping the write compatible with
+ // dialects that cannot return generated keys for every generation strategy.
+ IdentityHashMap keyConsumers = new IdentityHashMap<>();
+ for (Node node : discoveryOrder) {
+ for (Dependency dependency : node.dependencies) {
+ keyConsumers.put(dependency.target(), Boolean.TRUE);
+ }
+ }
+ // Execute level by level, batched per type. Discovered members are always inserted; explicit members are
+ // inserted or upserted according to the verb.
+ IdentityHashMap persistedView = new IdentityHashMap<>();
+ for (Map, List> byType : groupByLevelAndType(discoveryOrder)) {
+ for (var entry : byType.entrySet()) {
+ TypeInfo info = typeInfo(entry.getKey());
+ if (verb == Verb.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);
+ persistBatch(info, entry.getValue().stream().filter(node -> node.passed).toList(),
+ persistedView, Verb.UPSERT, fetchKeys, keyConsumers);
+ } else {
+ persistBatch(info, entry.getValue(), persistedView, Verb.INSERT, fetchKeys, keyConsumers);
+ }
+ }
+ }
+ return new Execution(inputs, persistedView);
+ }
+
+ /** Groups the nodes into per-level maps of type to nodes, preserving discovery order within each group. */
+ private static List, List>> groupByLevelAndType(@Nonnull List nodes) {
+ int maxLevel = nodes.stream().mapToInt(node -> node.level).max().orElse(-1);
+ List, List>> levels = new ArrayList<>(maxLevel + 1);
+ for (int level = 0; level <= maxLevel; level++) {
+ levels.add(new LinkedHashMap<>());
+ }
+ for (Node node : nodes) {
+ levels.get(node.level).computeIfAbsent(node.entity.getClass(), type -> new ArrayList<>()).add(node);
+ }
+ return levels;
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private void persistBatch(@Nonnull TypeInfo info,
+ @Nonnull List nodes,
+ @Nonnull IdentityHashMap persistedView,
+ @Nonnull Verb verb,
+ boolean fetchKeys,
+ @Nonnull IdentityHashMap keyConsumers) {
+ if (nodes.isEmpty()) {
+ return;
+ }
+ List prepared = new ArrayList<>(nodes.size());
+ for (Node node : nodes) {
+ prepared.add(propagateKeys(node, persistedView));
+ }
+ EntityRepository repository = info.repository;
+ boolean keysNeeded = info.autoGeneratedPrimaryKey && nodes.stream().anyMatch(
+ node -> keyConsumers.containsKey(node.entity) || (fetchKeys && node.passed));
+ 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
+ ? 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) {
+ repository.upsert(prepared);
+ } else {
+ repository.insert(prepared);
+ }
+ for (int i = 0; i < nodes.size(); i++) {
+ persistedView.put(nodes.get(i).entity, prepared.get(i));
+ }
+ }
+ }
+
+ /** Rebuilds the node's entity with every unsaved FK reference replaced by its persisted counterpart. */
+ private Object propagateKeys(@Nonnull Node node, @Nonnull IdentityHashMap persistedView) {
+ Object entity = node.entity;
+ for (Dependency dependency : node.dependencies) {
+ Object persisted = persistedView.get(dependency.target());
+ if (persisted == null) {
+ // Level ordering guarantees dependencies are persisted first; this indicates an internal error.
+ throw new PersistenceException("Internal error: dependency %s of %s has not been persisted."
+ .formatted(dependency.target().getClass().getSimpleName(),
+ entity.getClass().getSimpleName()));
+ }
+ FkEdge edge = dependency.edge();
+ // Wrapping the persisted instance preserves its concrete type (which may be a subtype of the declared
+ // component type) and hands dependents a loaded ref rather than an id-only one.
+ Object newValue = edge.ref
+ ? Ref.of((Entity>) persisted)
+ : persisted;
+ entity = withComponent(entity, edge.path, 0, newValue);
+ }
+ return entity;
+ }
+
+ //
+ // Update.
+ //
+
+ private Execution executeUpdate(@Nonnull Iterable extends Entity>> entities) {
+ List inputs = new ArrayList<>();
+ Map, List> byType = new LinkedHashMap<>();
+ for (Entity> entity : entities) {
+ if (isUnsaved(entity)) {
+ throw new PersistenceException(("Cannot update unsaved %s. Its primary key is the default value; " +
+ "insert it instead, or use upsert.").formatted(entity.getClass().getSimpleName()));
+ }
+ inputs.add(entity);
+ byType.computeIfAbsent(entity.getClass(), type -> new ArrayList<>()).add(entity);
+ }
+ for (var entry : byType.entrySet()) {
+ update(typeInfo(entry.getKey()).repository, entry.getValue());
+ }
+ IdentityHashMap persistedView = new IdentityHashMap<>();
+ for (Object input : inputs) {
+ persistedView.put(input, input);
+ }
+ return new Execution(inputs, persistedView);
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private void update(@Nonnull EntityRepository repository, @Nonnull List batch) {
+ repository.update((Iterable) batch);
+ }
+
+ //
+ // Remove.
+ //
+
+ private void executeRemove(@Nonnull Iterable extends Entity>> entities) {
+ // Members are correlated by primary key rather than instance identity: two instances describing the same row
+ // are removed once, and dependencies hold regardless of which instance a member embeds.
+ Map members = new LinkedHashMap<>();
+ for (Entity> entity : entities) {
+ if (isUnsaved(entity)) {
+ throw new PersistenceException(("Cannot remove unsaved %s. Its primary key is the default value, " +
+ "so it does not describe a database row.").formatted(entity.getClass().getSimpleName()));
+ }
+ members.putIfAbsent(new TypeIdKey(entity.getClass(), entity.id()), entity);
+ }
+ // Build member-to-member dependencies via FK values; children must be removed before their parents.
+ IdentityHashMap nodes = new IdentityHashMap<>();
+ List order = new ArrayList<>();
+ for (Object member : members.values()) {
+ Node node = new Node(member, true);
+ nodes.put(member, node);
+ order.add(node);
+ }
+ for (Node node : order) {
+ TypeInfo info = typeInfo(node.entity.getClass());
+ for (FkEdge edge : info.fkEdges) {
+ Object targetId = resolveTargetId(node.entity, edge);
+ if (targetId == null) {
+ continue;
+ }
+ Object parent = members.get(new TypeIdKey(edge.targetType, targetId));
+ if (parent != null && parent != node.entity) {
+ node.orderingDependencies.add(nodes.get(parent));
+ }
+ }
+ }
+ assignLevels(nodes, order);
+ // Reverse level order: the deepest dependents go first, their referenced members last.
+ List, List>> levels = groupByLevelAndType(order);
+ for (int level = levels.size() - 1; level >= 0; level--) {
+ for (var entry : levels.get(level).entrySet()) {
+ remove(typeInfo(entry.getKey()).repository,
+ entry.getValue().stream().map(node -> node.entity).toList());
+ }
+ }
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private void remove(@Nonnull EntityRepository repository, @Nonnull List batch) {
+ repository.remove((Iterable) batch);
+ }
+
+ /** Key correlating remove members by row rather than by instance. */
+ private record TypeIdKey(Class> type, Object id) {}
+
+ //
+ // Shared machinery.
+ //
+
+ /**
+ * Assigns each node the length of its longest dependency chain, so that a node always lands on a higher level
+ * than everything it depends on. Fails fast when a dependency cycle prevents an ordering.
+ */
+ static void assignLevels(@Nonnull IdentityHashMap nodes, @Nonnull List order) {
+ List remaining = new ArrayList<>(order);
+ while (!remaining.isEmpty()) {
+ boolean progressed = false;
+ List next = new ArrayList<>();
+ for (Node node : remaining) {
+ int level = 0;
+ boolean ready = true;
+ for (Dependency dependency : node.dependencies) {
+ Node dependencyNode = nodes.get(dependency.target());
+ if (dependencyNode.level < 0) {
+ ready = false;
+ break;
+ }
+ level = Math.max(level, dependencyNode.level + 1);
+ }
+ for (Node dependencyNode : node.orderingDependencies) {
+ if (!ready) {
+ break;
+ }
+ if (dependencyNode.level < 0) {
+ ready = false;
+ break;
+ }
+ level = Math.max(level, dependencyNode.level + 1);
+ }
+ if (ready) {
+ node.level = level;
+ progressed = true;
+ } else {
+ next.add(node);
+ }
+ }
+ if (!progressed) {
+ String cycle = next.stream()
+ .limit(8)
+ .map(node -> "%s@%08x".formatted(node.entity.getClass().getSimpleName(),
+ System.identityHashCode(node.entity)))
+ .reduce((a, b) -> a + ", " + b)
+ .orElse("");
+ throw new PersistenceException(("Cannot determine a valid write order: the foreign key dependencies " +
+ "among [%s] form a cycle. Break the cycle by persisting one side first, or by referencing " +
+ "one side through an id-only Ref.").formatted(cycle));
+ }
+ remaining = next;
+ }
+ }
+
+ /**
+ * Resolves the entity instance referenced by the given FK component, or {@code null} when nothing is referenced
+ * or the reference is keyed by id only. Id-only refs carrying a default id cannot join the closure and fail fast
+ * for insert and upsert.
+ */
+ @Nullable
+ private Object resolveTarget(@Nonnull Object entity, @Nonnull FkEdge edge, @Nonnull Verb verb) {
+ Object value = valueAt(entity, edge.path);
+ if (value == null) {
+ return null;
+ }
+ if (edge.ref) {
+ Ref> ref = (Ref>) value;
+ Object wrapped = ref.getOrNull();
+ if (wrapped != null) {
+ return wrapped;
+ }
+ if ((verb == Verb.INSERT || verb == Verb.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,
+ edge.targetType.getSimpleName()));
+ }
+ return null;
+ }
+ return value;
+ }
+
+ /** Resolves the primary key value referenced by the given FK component, or {@code null}. */
+ @Nullable
+ private Object resolveTargetId(@Nonnull Object entity, @Nonnull FkEdge edge) {
+ Object value = valueAt(entity, edge.path);
+ if (value == null) {
+ return null;
+ }
+ if (edge.ref) {
+ return ((Ref>) value).id();
+ }
+ return ((Entity>) value).id();
+ }
+
+ private boolean isUnsaved(@Nonnull Object entity) {
+ TypeInfo info = typeInfo(entity.getClass());
+ return info.autoGeneratedPrimaryKey && isDefaultPrimaryKey(info.model, ((Entity>) entity).id());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static boolean isDefaultPrimaryKey(@Nonnull Model, ID> model, @Nullable Object id) {
+ return model.isDefaultPrimaryKey((ID) id);
+ }
+
+ @Nullable
+ private Object valueAt(@Nonnull Object record, @Nonnull int[] path) {
+ Object current = record;
+ for (int index : path) {
+ if (current == null) {
+ return null;
+ }
+ current = REFLECTION.getRecordValue(current, index);
+ }
+ return current;
+ }
+
+ /**
+ * Rebuilds the record with the component at the given path replaced, reconstructing nested records as needed.
+ *
+ * Intermediate components along the path are never {@code null} here: a dependency is only recorded when
+ * {@link #valueAt(Object, int[])} resolved a non-null target through the same path, and records are
+ * immutable.
+ */
+ private Object withComponent(@Nonnull Object record, @Nonnull int[] path, int depth, @Nullable Object newValue) {
+ RecordType recordType = REFLECTION.getRecordType(record.getClass());
+ List fields = recordType.fields();
+ Object[] args = new Object[fields.size()];
+ for (int i = 0; i < fields.size(); i++) {
+ args[i] = REFLECTION.getRecordValue(record, i);
+ }
+ int index = path[depth];
+ args[index] = depth == path.length - 1
+ ? newValue
+ : withComponent(args[index], path, depth + 1, newValue);
+ recordType.constructor().setAccessible(true);
+ return recordType.newInstance(args);
+ }
+
+ /** Rebuilds the entity with the given primary key. */
+ private Object withPrimaryKey(@Nonnull TypeInfo info, @Nonnull Object entity, @Nonnull Object pk) {
+ return withComponent(entity, new int[] {info.primaryKeyIndex}, 0, pk);
+ }
+
+ //
+ // Fetch support.
+ //
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ @Nonnull
+ private List> fetch(@Nonnull Execution execution) {
+ // Collect the ids of the passed entities per type, preserving first-seen type order.
+ Map, List> idsByType = new LinkedHashMap<>();
+ for (Object input : execution.inputs()) {
+ Object persisted = execution.persistedView().get(input);
+ Object id = ((Entity>) requireNonNull(persisted, "persisted view")).id();
+ idsByType.computeIfAbsent(input.getClass(), type -> new ArrayList<>()).add(id);
+ }
+ Map> fetched = new HashMap<>();
+ for (var entry : idsByType.entrySet()) {
+ EntityRepository repository = typeInfo(entry.getKey()).repository;
+ for (Object entity : (List) repository.findAllById((Iterable) entry.getValue())) {
+ Entity> fetchedEntity = (Entity>) entity;
+ fetched.put(new TypeIdKey(entry.getKey(), fetchedEntity.id()), fetchedEntity);
+ }
+ }
+ List> result = new ArrayList<>(execution.inputs().size());
+ for (Object input : execution.inputs()) {
+ Object id = ((Entity>) execution.persistedView().get(input)).id();
+ Entity> fetchedEntity = fetched.get(new TypeIdKey(input.getClass(), id));
+ if (fetchedEntity == null) {
+ throw new PersistenceException("Failed to fetch %s with id %s after write."
+ .formatted(input.getClass().getSimpleName(), id));
+ }
+ result.add(fetchedEntity);
+ }
+ return result;
+ }
+
+ //
+ // Per-type metadata.
+ //
+
+ /** An FK component of an entity type: the component path from the root record, its kind and its target type. */
+ private record FkEdge(int[] path, String name, boolean ref, Class extends Data> targetType, boolean insertable) {}
+
+ private final class TypeInfo {
+ final Model, ?> model;
+ @SuppressWarnings("rawtypes")
+ final EntityRepository repository;
+ final boolean autoGeneratedPrimaryKey;
+ final int primaryKeyIndex;
+ final List fkEdges;
+
+ @SuppressWarnings("unchecked")
+ TypeInfo(@Nonnull Class> type) {
+ this.repository = lookup.entity((Class>) type);
+ this.model = repository.model();
+ this.autoGeneratedPrimaryKey = model.declaredColumns().stream()
+ .filter(Column::primaryKey)
+ .anyMatch(column -> column.generation() != GenerationStrategy.NONE);
+ RecordType recordType = REFLECTION.getRecordType(type);
+ int primaryKeyFieldIndex = -1;
+ List fields = recordType.fields();
+ for (int i = 0; i < fields.size(); i++) {
+ if (fields.get(i).isAnnotationPresent(PK.class)) {
+ primaryKeyFieldIndex = i;
+ break;
+ }
+ }
+ if (autoGeneratedPrimaryKey && primaryKeyFieldIndex < 0) {
+ throw new PersistenceException(("Cannot use %s in a write set: its primary key is auto-generated " +
+ "but no @PK component was found to carry the generated key.").formatted(type.getSimpleName()));
+ }
+ this.primaryKeyIndex = primaryKeyFieldIndex;
+ List edges = new ArrayList<>();
+ collectFkEdges(recordType, new ArrayList<>(), true, edges);
+ this.fkEdges = List.copyOf(edges);
+ }
+ }
+
+ private TypeInfo typeInfo(@Nonnull Class> type) {
+ return typeInfoCache.computeIfAbsent(type, TypeInfo::new);
+ }
+
+ /**
+ * Collects the FK components of the given record type, recursing into inline records. The {@code insertable}
+ * flag tracks inherited {@code @Persist} semantics: an inline component marked non-insertable propagates to its
+ * children.
+ */
+ private void collectFkEdges(@Nonnull RecordType recordType,
+ @Nonnull List path,
+ boolean insertable,
+ @Nonnull List edges) {
+ List fields = recordType.fields();
+ for (int i = 0; i < fields.size(); i++) {
+ RecordField field = fields.get(i);
+ Persist persist = field.getAnnotation(Persist.class);
+ boolean fieldInsertable = insertable && (persist == null || persist.insertable());
+ if (field.isAnnotationPresent(FK.class)) {
+ path.add(i);
+ if (Ref.class.isAssignableFrom(field.type())) {
+ // Only refs to entities can act as write-set edges; refs to projections merely bind their id.
+ Class extends Data> targetType = refTargetType(field);
+ if (Entity.class.isAssignableFrom(targetType)) {
+ edges.add(new FkEdge(toArray(path), field.name(), true, targetType, fieldInsertable));
+ }
+ } else if (field.isDataType() && Entity.class.isAssignableFrom(field.type())) {
+ edges.add(new FkEdge(toArray(path), field.name(), false,
+ (Class extends Data>) field.type(), fieldInsertable));
+ }
+ path.remove(path.size() - 1);
+ continue;
+ }
+ if (Entity.class.isAssignableFrom(field.type()) || Ref.class.isAssignableFrom(field.type())) {
+ // Entity and Ref components without @FK are not write-set edges.
+ continue;
+ }
+ // Recurse into inline records (including composite primary keys), which may carry FK components.
+ var nested = REFLECTION.findRecordType(field.type());
+ if (nested.isPresent()) {
+ path.add(i);
+ collectFkEdges(nested.get(), path, fieldInsertable, edges);
+ path.remove(path.size() - 1);
+ }
+ }
+ }
+
+ private static int[] toArray(@Nonnull List path) {
+ int[] result = new int[path.size()];
+ for (int i = 0; i < path.size(); i++) {
+ result[i] = path.get(i);
+ }
+ return result;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Class extends Data> refTargetType(@Nonnull RecordField field) {
+ if (field.genericType() instanceof ParameterizedType parameterizedType
+ && parameterizedType.getActualTypeArguments().length == 1
+ && parameterizedType.getActualTypeArguments()[0] instanceof Class> targetType
+ && Data.class.isAssignableFrom(targetType)) {
+ return (Class extends Data>) targetType;
+ }
+ throw new PersistenceException("Cannot determine the target type of Ref component '%s.%s'; found '%s'."
+ .formatted(field.declaringType().getSimpleName(), field.name(), field.genericType()));
+ }
+}
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
index 6409488c9..5778eee09 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
@@ -520,7 +520,8 @@ public void validateForeignKeys(@Nonnull List columns, @Nonnull E record
"Foreign key '%s.%s' has a default primary key value. "
.formatted(record.getClass().getSimpleName(), column.name())
+ "This typically indicates an unsaved entity is being used as a reference. "
- + "Ensure the referenced entity has been persisted before using it as a foreign key.");
+ + "Ensure the referenced entity has been persisted before using it as a foreign key, "
+ + "or insert the whole graph in one operation via writeSet().");
}
}
}
diff --git a/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
new file mode 100644
index 000000000..919fc5988
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
@@ -0,0 +1,376 @@
+package st.orm.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import st.orm.Entity;
+import st.orm.PersistenceException;
+import st.orm.Ref;
+import st.orm.core.model.Address;
+import st.orm.core.model.City;
+import st.orm.core.model.Owner;
+import st.orm.core.model.Pet;
+import st.orm.core.model.PetOwnerRef;
+import st.orm.core.model.PetType;
+import st.orm.core.model.Specialty;
+import st.orm.core.model.Vet;
+import st.orm.core.model.VetSpecialty;
+import st.orm.core.model.VetSpecialtyPK;
+import st.orm.core.model.Visit;
+import st.orm.core.repository.EntityRepository;
+import st.orm.core.template.ORMTemplate;
+import st.orm.core.template.SqlInterceptor;
+
+@SuppressWarnings("ALL")
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = IntegrationConfig.class)
+@DataJpaTest(showSql = false)
+public class WriteSetIntegrationTest {
+
+ @Autowired
+ private DataSource dataSource;
+
+ private ORMTemplate orm() {
+ return ORMTemplate.of(dataSource);
+ }
+
+ private Owner newOwner(String firstName, String lastName) {
+ return Owner.builder()
+ .firstName(firstName)
+ .lastName(lastName)
+ .address(Address.builder().address("110 W. Liberty St.").city(City.builder().id(1).name("Sun Paririe").build()).build())
+ .telephone("6085551023")
+ .build();
+ }
+
+ private Ref dogType() {
+ return Ref.of(PetType.class, 1);
+ }
+
+ @Test
+ public void testInsertThreeLevelGraphWithSharedParent() {
+ var orm = orm();
+ var owner = newOwner("Alice", "WriteSet");
+ var wolfie = Pet.builder().name("Wolfie").birthDate(LocalDate.of(2024, 1, 1)).type(dogType()).owner(owner).build();
+ var rex = Pet.builder().name("Rex").birthDate(LocalDate.of(2024, 2, 2)).type(dogType()).owner(owner).build();
+ var visit = new Visit(LocalDate.of(2026, 7, 14), "Check-up", wolfie);
+ List inserts = new ArrayList<>();
+ SqlInterceptor.observe(
+ sql -> { if (sql.statement().toUpperCase().startsWith("INSERT")) inserts.add(sql.statement()); },
+ () -> { orm.writeSet().insert(List.of(wolfie, rex, visit)); return null; });
+ // One statement per type per level: owner, pets (batched), visit.
+ assertEquals(3, inserts.size());
+ // The shared owner instance is inserted exactly once.
+ var owners = orm.entity(Owner.class).select().getResultList().stream()
+ .filter(fetched -> fetched.lastName().equals("WriteSet"))
+ .toList();
+ assertEquals(1, owners.size());
+ var pets = orm.entity(Pet.class).select().getResultList().stream()
+ .filter(pet -> pet.owner() != null && pet.owner().id().equals(owners.getFirst().id()))
+ .toList();
+ assertEquals(2, pets.size());
+ var visits = orm.entity(Visit.class).select().getResultList().stream()
+ .filter(fetched -> "Check-up".equals(fetched.description()))
+ .toList();
+ assertEquals(1, visits.size());
+ assertEquals("Wolfie", visits.getFirst().pet().name());
+ }
+
+ @Test
+ public void testInsertClosurePullsInUnsavedParents() {
+ var orm = orm();
+ var owner = newOwner("Closure", "Only");
+ var pet = Pet.builder().name("Shadow").birthDate(LocalDate.of(2023, 3, 3)).type(dogType()).owner(owner).build();
+ var visit = new Visit(LocalDate.of(2026, 1, 1), "Closure visit", pet);
+ // Only the visit is passed; pet and owner join via the insertion closure.
+ orm.writeSet().insert(List.of(visit));
+ var fetched = orm.entity(Visit.class).select().getResultList().stream()
+ .filter(candidate -> "Closure visit".equals(candidate.description()))
+ .toList();
+ assertEquals(1, fetched.size());
+ assertEquals("Shadow", fetched.getFirst().pet().name());
+ assertNotNull(fetched.getFirst().pet().owner());
+ assertEquals("Closure", fetched.getFirst().pet().owner().firstName());
+ }
+
+ @Test
+ public void testInsertAndFetchReturnsInputOrder() {
+ var orm = orm();
+ var owner = newOwner("Fetch", "Order");
+ var pet = Pet.builder().name("First").birthDate(LocalDate.of(2022, 1, 1)).type(dogType()).owner(owner).build();
+ var visit = new Visit(LocalDate.of(2026, 2, 2), "Fetch visit", pet);
+ var fetched = orm.writeSet().insertAndFetch(List.of(visit, pet));
+ assertEquals(2, fetched.size());
+ var fetchedVisit = (Visit) fetched.get(0);
+ var fetchedPet = (Pet) fetched.get(1);
+ assertEquals("Fetch visit", fetchedVisit.description());
+ assertEquals("First", fetchedPet.name());
+ // The re-fetched graph is hydrated with generated keys throughout.
+ assertNotEquals(0, fetchedPet.id());
+ assertNotNull(fetchedPet.owner());
+ assertNotEquals(0, fetchedPet.owner().id());
+ assertEquals(fetchedPet.id(), fetchedVisit.pet().id());
+ }
+
+ @Test
+ public void testInsertUnsavedParentThroughInlineComponent() {
+ var orm = orm();
+ // The new city sits inside the owner's inline address component.
+ var owner = Owner.builder()
+ .firstName("Inline")
+ .lastName("City")
+ .address(Address.builder().address("1 Inline Way").city(City.builder().name("Graphville").build()).build())
+ .build();
+ orm.writeSet().insert(List.of(owner));
+ var fetched = orm.entity(Owner.class).select().getResultList().stream()
+ .filter(candidate -> "Inline".equals(candidate.firstName()))
+ .toList();
+ assertEquals(1, fetched.size());
+ assertNotNull(fetched.getFirst().address().city());
+ assertEquals("Graphville", fetched.getFirst().address().city().name());
+ assertNotEquals(0, fetched.getFirst().address().city().id());
+ }
+
+ @Test
+ public void testInsertUnsavedParentThroughWrappedRef() {
+ var orm = orm();
+ var owner = newOwner("Wrapped", "Ref");
+ var pet = PetOwnerRef.builder()
+ .name("RefPet")
+ .birthDate(LocalDate.of(2021, 5, 5))
+ .petType(PetType.builder().id(1).name("dog").build())
+ .owner(Ref.of(owner))
+ .build();
+ orm.writeSet().insert(List.of(pet));
+ var fetched = orm.entity(PetOwnerRef.class).select().getResultList().stream()
+ .filter(candidate -> "RefPet".equals(candidate.name()))
+ .toList();
+ assertEquals(1, fetched.size());
+ var fetchedOwner = fetched.getFirst().owner().fetch();
+ assertEquals("Wrapped", fetchedOwner.firstName());
+ }
+
+ @Test
+ public void testInsertOrdersKeyedMembersByForeignKey() {
+ var orm = orm();
+ var ferretType = PetType.builder().id(7).name("ferret").build();
+ var pet = PetOwnerRef.builder()
+ .name("Ferry")
+ .birthDate(LocalDate.of(2020, 6, 6))
+ .petType(ferretType)
+ .owner(null)
+ .build();
+ List inserts = new ArrayList<>();
+ SqlInterceptor.observe(
+ sql -> { if (sql.statement().toUpperCase().startsWith("INSERT")) inserts.add(sql.statement().toLowerCase()); },
+ // The pet is passed first, but its natural-key pet type member must be inserted before it.
+ () -> { orm.writeSet().insert(List.of(pet, ferretType)); return null; });
+ assertEquals(2, inserts.size());
+ assertTrue(inserts.get(0).contains("pet_type"));
+ var fetched = orm.entity(PetOwnerRef.class).select().getResultList().stream()
+ .filter(candidate -> "Ferry".equals(candidate.name()))
+ .toList();
+ assertEquals(7, fetched.getFirst().petType().id());
+ }
+
+ @Test
+ public void testInsertIdOnlyRefWithDefaultIdFails() {
+ var orm = orm();
+ var pet = PetOwnerRef.builder()
+ .name("Dangling")
+ .birthDate(LocalDate.of(2020, 7, 7))
+ .petType(PetType.builder().id(1).name("dog").build())
+ .owner(Ref.of(Owner.class, 0))
+ .build();
+ var exception = assertThrows(PersistenceException.class, () -> orm.writeSet().insert(List.of(pet)));
+ assertTrue(exception.getMessage().contains("id-only Ref"));
+ assertTrue(exception.getMessage().contains("Ref.of(entity)"));
+ }
+
+ @Test
+ public void testInsertUnsavedThroughNonInsertableComponentFails() {
+ var orm = orm();
+ var vetSpecialty = new VetSpecialty(
+ new VetSpecialtyPK(0, 1),
+ Vet.builder().firstName("New").lastName("Vet").build(),
+ Specialty.builder().id(1).name("radiology").build());
+ var exception = assertThrows(PersistenceException.class, () -> orm.writeSet().insert(List.of(vetSpecialty)));
+ assertTrue(exception.getMessage().contains("not insertable"));
+ }
+
+ public interface PetGraphRepository extends EntityRepository {
+ default List> insertPetWithOwner(Owner owner, String petName) {
+ var pet = Pet.builder().name(petName).birthDate(LocalDate.of(2024, 3, 3))
+ .type(Ref.of(PetType.class, 1)).owner(owner).build();
+ return writeSet().insertAndFetch(List.of(pet));
+ }
+ }
+
+ @Test
+ public void testWriteSetAccessibleFromRepositories() {
+ var orm = orm();
+ // Directly on an entity repository: the write set is the template's, not scoped to the repository type.
+ orm.entity(Pet.class).writeSet().insert(List.of(newOwner("Direct", "RepoAccess")));
+ assertEquals(1, orm.entity(Owner.class).select().getResultList().stream()
+ .filter(candidate -> "RepoAccess".equals(candidate.lastName()))
+ .count());
+ // Through a custom repository default method, exercising the repository proxy dispatch.
+ var inserted = orm.repository(PetGraphRepository.class)
+ .insertPetWithOwner(newOwner("Proxy", "RepoAccess"), "ProxyPet");
+ assertEquals(1, inserted.size());
+ var fetchedPet = (Pet) inserted.getFirst();
+ assertEquals("ProxyPet", fetchedPet.name());
+ assertNotNull(fetchedPet.owner());
+ assertEquals("Proxy", fetchedPet.owner().firstName());
+ }
+
+ @Test
+ public void testSingleRootConvenienceVariants() {
+ var orm = orm();
+ var owner = newOwner("Single", "Root");
+ var pet = Pet.builder().name("SingleRootPet").birthDate(LocalDate.of(2024, 7, 7)).type(dogType()).owner(owner).build();
+ var visit = new Visit(LocalDate.of(2026, 5, 5), "Single root visit", pet);
+ // Typed single-root variants: the whole graph goes in, the typed root comes out.
+ Visit inserted = orm.writeSet().insertAndFetch(visit);
+ assertEquals("Single root visit", inserted.description());
+ assertNotEquals(0, inserted.pet().id());
+ assertNotNull(inserted.pet().owner());
+ Visit renamed = orm.writeSet().updateAndFetch(inserted.toBuilder().description("Renamed visit").build());
+ assertEquals("Renamed visit", renamed.description());
+ orm.writeSet().remove(renamed);
+ assertTrue(orm.entity(Visit.class).findById(renamed.id()).isEmpty());
+ }
+
+ @Test
+ public void testEqualButDistinctUnsavedParentsProduceSeparateRows() {
+ var orm = orm();
+ var firstTwin = newOwner("Twin", "Identity");
+ var secondTwin = newOwner("Twin", "Identity");
+ // The two owners are equal as values but distinct instances: they describe two prospective rows.
+ assertEquals(firstTwin, secondTwin);
+ var wolfie = Pet.builder().name("TwinPetA").birthDate(LocalDate.of(2024, 5, 5)).type(dogType()).owner(firstTwin).build();
+ var rex = Pet.builder().name("TwinPetB").birthDate(LocalDate.of(2024, 6, 6)).type(dogType()).owner(secondTwin).build();
+ orm.writeSet().insert(List.of(wolfie, rex));
+ var owners = orm.entity(Owner.class).select().getResultList().stream()
+ .filter(candidate -> "Identity".equals(candidate.lastName()))
+ .toList();
+ assertEquals(2, owners.size());
+ assertNotEquals(owners.get(0).id(), owners.get(1).id());
+ }
+
+ @Test
+ public void testUpdateDoesNotWriteReferencedEntities() {
+ var orm = orm();
+ var pet = orm.entity(Pet.class).getById(1);
+ assertNotNull(pet.owner());
+ var mutatedOwner = pet.owner().toBuilder().firstName("Mutated").build();
+ // The pet's foreign key value (the owner's id) is unchanged, so only the pet row is written.
+ orm.writeSet().update(List.of(pet.toBuilder().name("TouchedPet").owner(mutatedOwner).build()));
+ assertEquals("TouchedPet", orm.entity(Pet.class).getById(1).name());
+ assertNotEquals("Mutated", orm.entity(Owner.class).getById(pet.owner().id()).firstName());
+ }
+
+ @Test
+ public void testRemoveDoesNotRemoveReferencedEntities() {
+ var orm = orm();
+ var owner = newOwner("Keep", "Me");
+ var pet = Pet.builder().name("Kept").birthDate(LocalDate.of(2020, 2, 2)).type(dogType()).owner(owner).build();
+ var visit = new Visit(LocalDate.of(2026, 4, 4), "Only removed member", pet);
+ var inserted = orm.writeSet().insertAndFetch(List.of(visit));
+ // Only the visit is an explicit member; the pet and owner it references must survive.
+ orm.writeSet().remove(List.of(inserted.getFirst()));
+ assertTrue(orm.entity(Visit.class).findById(((Visit) inserted.getFirst()).id()).isEmpty());
+ assertEquals(1, orm.entity(Pet.class).select().getResultList().stream()
+ .filter(candidate -> "Kept".equals(candidate.name()))
+ .count());
+ assertEquals(1, orm.entity(Owner.class).select().getResultList().stream()
+ .filter(candidate -> "Keep".equals(candidate.firstName()))
+ .count());
+ }
+
+ @Test
+ public void testUpdateMixedTypes() {
+ var orm = orm();
+ var owner = orm.entity(Owner.class).getById(1).toBuilder().telephone("0000000000").build();
+ var pet = orm.entity(Pet.class).getById(1).toBuilder().name("Renamed").build();
+ orm.writeSet().update(List.of(owner, pet));
+ assertEquals("0000000000", orm.entity(Owner.class).getById(1).telephone());
+ assertEquals("Renamed", orm.entity(Pet.class).getById(1).name());
+ }
+
+ @Test
+ public void testUpdateAndFetchReflectsDatabaseState() {
+ var orm = orm();
+ var owner = orm.entity(Owner.class).getById(1);
+ var updated = orm.writeSet().updateAndFetch(List.of(owner.toBuilder().telephone("1111111111").build()));
+ assertEquals(1, updated.size());
+ var fetchedOwner = (Owner) updated.getFirst();
+ assertEquals("1111111111", fetchedOwner.telephone());
+ // The version column is bumped by the update and reflected by the fetch.
+ assertEquals(owner.version() + 1, fetchedOwner.version());
+ }
+
+ @Test
+ public void testUpdateUnsavedFails() {
+ var orm = orm();
+ var exception = assertThrows(PersistenceException.class,
+ () -> orm.writeSet().update(List.of(newOwner("Not", "Saved"))));
+ assertTrue(exception.getMessage().contains("unsaved"));
+ }
+
+ @Test
+ public void testUpsertInsertsClosureMembersBeforeDelegating() {
+ // The default H2 implementation does not support upsert; per-repository upsert throws. The write set must
+ // surface that same exception for the passed members, but only after the unsaved closure members have been
+ // resolved, showing the delegation reaches the per-type upsert unchanged.
+ var orm = orm();
+ var newOwner = newOwner("Upsert", "Fresh");
+ var pet = Pet.builder().name("Upserted").birthDate(LocalDate.of(2019, 9, 9)).type(dogType()).owner(newOwner).build();
+ var exception = assertThrows(PersistenceException.class, () -> orm.writeSet().upsert(List.of(pet)));
+ assertTrue(exception.getMessage().contains("Upsert is not available"), exception.getMessage());
+ // The closure member was inserted before the upsert of the passed member failed.
+ var owners = orm.entity(Owner.class).select().getResultList().stream()
+ .filter(candidate -> "Upsert".equals(candidate.firstName()))
+ .toList();
+ assertEquals(1, owners.size());
+ }
+
+ @Test
+ public void testRemoveChildrenBeforeParents() {
+ var orm = orm();
+ var owner = newOwner("Remove", "Me");
+ var pet = Pet.builder().name("Removable").birthDate(LocalDate.of(2018, 8, 8)).type(dogType()).owner(owner).build();
+ var visit = new Visit(LocalDate.of(2026, 3, 3), "Remove visit", pet);
+ var inserted = orm.writeSet().insertAndFetch(List.of(owner, pet, visit));
+ var insertedOwner = (Owner) inserted.get(0);
+ var insertedPet = (Pet) inserted.get(1);
+ var insertedVisit = (Visit) inserted.get(2);
+ // Parents first in the argument list; the write set must reorder (H2 enforces the FK constraints).
+ orm.writeSet().remove(List.of(insertedOwner, insertedPet, insertedVisit));
+ assertTrue(orm.entity(Owner.class).findById(insertedOwner.id()).isEmpty());
+ assertTrue(orm.entity(Pet.class).findById(insertedPet.id()).isEmpty());
+ assertTrue(orm.entity(Visit.class).findById(insertedVisit.id()).isEmpty());
+ }
+
+ @Test
+ public void testRemoveUnsavedFails() {
+ var orm = orm();
+ var exception = assertThrows(PersistenceException.class,
+ () -> orm.writeSet().remove(List.of(newOwner("Never", "Persisted"))));
+ assertTrue(exception.getMessage().contains("unsaved"));
+ }
+
+}
diff --git a/storm-core/src/test/java/st/orm/core/repository/impl/WriteSetLevelingTest.java b/storm-core/src/test/java/st/orm/core/repository/impl/WriteSetLevelingTest.java
new file mode 100644
index 000000000..8ff328f7f
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/repository/impl/WriteSetLevelingTest.java
@@ -0,0 +1,57 @@
+package st.orm.core.repository.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.IdentityHashMap;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import st.orm.PersistenceException;
+import st.orm.core.repository.impl.WriteSetImpl.Node;
+
+/**
+ * Unit tests for the dependency leveling of {@link WriteSetImpl}.
+ *
+ * The cycle guard cannot be reached through the integration schema: unsaved record graphs are acyclic by
+ * construction, and keyed cycles require mutually referencing rows, which the test schema does not model. The
+ * leveling logic is therefore exercised directly.
+ */
+public class WriteSetLevelingTest {
+
+ private static Node node(Object entity) {
+ return new Node(entity, true);
+ }
+
+ @Test
+ public void testLevelsFollowLongestDependencyChain() {
+ Node owner = node("owner");
+ Node pet = node("pet");
+ Node visit = node("visit");
+ pet.orderingDependencies.add(owner);
+ visit.orderingDependencies.add(pet);
+ visit.orderingDependencies.add(owner);
+ IdentityHashMap nodes = new IdentityHashMap<>();
+ for (Node current : List.of(owner, pet, visit)) {
+ nodes.put(current.entity, current);
+ }
+ WriteSetImpl.assignLevels(nodes, List.of(visit, pet, owner));
+ assertEquals(0, owner.level);
+ assertEquals(1, pet.level);
+ assertEquals(2, visit.level);
+ }
+
+ @Test
+ public void testCycleFailsWithDescriptiveError() {
+ Node first = node("first");
+ Node second = node("second");
+ first.orderingDependencies.add(second);
+ second.orderingDependencies.add(first);
+ IdentityHashMap nodes = new IdentityHashMap<>();
+ nodes.put(first.entity, first);
+ nodes.put(second.entity, second);
+ var exception = assertThrows(PersistenceException.class,
+ () -> WriteSetImpl.assignLevels(nodes, List.of(first, second)));
+ assertTrue(exception.getMessage().contains("cycle"), exception.getMessage());
+ }
+}
diff --git a/storm-foundation/src/main/java/st/orm/Ref.java b/storm-foundation/src/main/java/st/orm/Ref.java
index 103fb2ade..aaaf6c462 100644
--- a/storm-foundation/src/main/java/st/orm/Ref.java
+++ b/storm-foundation/src/main/java/st/orm/Ref.java
@@ -64,6 +64,12 @@ static Ref of(@Nonnull Class type, @Nonnull ID pk) {
/**
* Creates a fully loaded ref instance that wraps the given entity.
*
+ * The entity does not need to be persisted yet: a ref wrapping an unsaved entity can act as a graph edge for
+ * dependency-aware write sets (see {@link WriteSet}), which insert the wrapped instance first and bind the
+ * generated key. Outside a write set, using an unsaved wrapped ref as a foreign key value fails fast at persist
+ * time. Note that ref equality is based on type and id, so refs wrapping distinct unsaved entities compare equal
+ * until the entities are persisted.
+ *
* @param entity the fully loaded entity to wrap in a ref.
* @param the type of the entity, which must extend {@link Record} and implement {@link Entity}.
* @return a fully loaded ref instance for the provided entity.
@@ -74,7 +80,6 @@ class DetachedEntity> extends AbstractRef {
DetachedEntity(@Nonnull TE entity) {
requireNonNull(entity, "Entity cannot be null.");
- requireNonNull(entity.id(), "Entity ID cannot be null.");
this.entity = entity;
}
diff --git a/storm-foundation/src/main/java/st/orm/WriteSet.java b/storm-foundation/src/main/java/st/orm/WriteSet.java
new file mode 100644
index 000000000..69a9b0d8d
--- /dev/null
+++ b/storm-foundation/src/main/java/st/orm/WriteSet.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm;
+
+import jakarta.annotation.Nonnull;
+import java.util.List;
+
+/**
+ * Dependency-aware write operations over mixed-type sets of entities.
+ *
+ * A write set applies one write operation to a heterogeneous collection of entities. {@link #insert(Iterable)}
+ * 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
+ * {@code EntityRepository} operation; the write set adds partitioning by type, dependency ordering and generated-key
+ * propagation:
+ *
+ *
+ * Insertion closure. A record whose foreign key field holds an unsaved entity is a value that
+ * describes both rows; inserting the value inserts both. Discovery traverses entity-valued foreign key fields
+ * (including fields inside inline components) and entity-wrapped refs (see {@link Ref#of(Entity)}). Referenced
+ * entities that already carry a primary key are never discovered; unless they are explicit members themselves,
+ * they only provide foreign key values.
+ * Ordering. A valid execution order is determined from the foreign key dependencies:
+ * parents-before-children for {@link #insert(Iterable)} and {@link #upsert(Iterable)},
+ * children-before-parents for {@link #remove(Iterable)}. {@link #update(Iterable)} has no ordering
+ * constraints and is only grouped by type.
+ * Key propagation. Generated primary keys propagate within the set by instance
+ * identity : a child links to its new parent by holding the same instance, either directly in the foreign
+ * key field or wrapped in a {@code Ref}. The same unsaved instance describes one prospective row; two
+ * structurally equal but distinct unsaved instances describe two rows.
+ * Batching. Execution is grouped into one batch operation per entity type per dependency
+ * level (large batches are split by the configured batch size). The number of batches follows the dependency
+ * shape of the data: the Owner ← Pet ← Visit example below needs three, and a self-referencing type
+ * whose rows span several dependency levels needs one batch per level.
+ *
+ *
+ * An entity is considered unsaved when its primary key is the default value and the primary key is
+ * auto-generated (identity or sequence). This test is local and deterministic; no session state, entity cache or
+ * database round trip is involved. There is no session-wide cascade or persistence context: all writes derive from
+ * the entities supplied to the call and, for insert and upsert, their insertion closure.
+ *
+ * A write set executes multiple statements and is not atomic by itself: when a later dependency level fails, the
+ * earlier levels have already been written. Run write sets inside a transaction when atomicity across the set is
+ * required.
+ *
+ * Example, inserting a three-level graph with a shared new parent:
+ *
+ * {@code
+ * var owner = new Owner("Alice", address); // unsaved
+ * var wolfie = new Pet("Wolfie", DOG, owner); // both pets share the owner instance
+ * var rex = new Pet("Rex", DOG, owner);
+ * var visit = new Visit(TODAY, "Check-up", wolfie);
+ * orm.writeSet().insert(List.of(wolfie, rex, visit)); // owner joins via the insertion closure:
+ * // one Owner, one Pet and one Visit batch
+ * }
+ *
+ * Unsaved references that cannot join the insertion closure fail fast with a descriptive exception before
+ * anything is written: an id-only {@code Ref} carrying a default id, an unsaved entity behind a non-insertable
+ * foreign key component, an unsaved entity encountered by {@link #update(Iterable)} or {@link #remove(Iterable)},
+ * and dependency cycles that cannot 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 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.
+ *
+ * Note on entity callbacks: callbacks run inside the per-type repository operations, after the
+ * write set has discovered members and planned the execution order. A callback that alters foreign key fields does
+ * not change which entities are discovered or in which order they are written.
+ *
+ * @see Ref#of(Entity)
+ * @since 1.13
+ */
+public interface WriteSet {
+
+ /**
+ * Inserts the explicit members and their insertion closure, in dependency order.
+ *
+ * All explicit members are inserted with the exact semantics of the per-repository insert: auto-generated
+ * primary keys are assigned by the database (a preset value on an auto-generated key is ignored), and entities
+ * with non-generated keys are inserted with the key they carry. 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.
+ *
+ * @param entities the entities to insert; may span multiple entity types.
+ * @throws PersistenceException if the dependencies contain a cycle that cannot be ordered, if an unsaved entity
+ * is referenced through a non-insertable foreign key component, or if the insert fails.
+ */
+ void insert(@Nonnull Iterable extends Entity>> entities);
+
+ /**
+ * Inserts like {@link #insert(Iterable)} and returns the explicit members as they exist in the database after
+ * the insert, in input order.
+ *
+ * The returned entities are re-fetched, so database-applied changes such as generated keys, defaults and
+ * version columns are reflected, and discovered members referenced by them are hydrated with their generated
+ * keys.
+ *
+ * @param entities the entities to insert; may span multiple entity types.
+ * @return the fetched entities in input order.
+ * @throws PersistenceException if the insert fails.
+ */
+ @Nonnull
+ List> insertAndFetch(@Nonnull Iterable extends Entity>> entities);
+
+ /**
+ * Updates the given entities, grouped by type.
+ *
+ * Per-row semantics are identical to the per-repository update, including transaction-scoped dirty checking:
+ * entities that are unchanged compared to their observed state are skipped. Only the explicit members are
+ * updated; referenced entities are never updated implicitly, and there is no insertion closure — an
+ * 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.
+ *
+ * @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.
+ */
+ void update(@Nonnull Iterable extends Entity>> entities);
+
+ /**
+ * Updates like {@link #update(Iterable)} and returns the passed entities as they exist in the database after the
+ * update, in input order.
+ *
+ * @param entities the entities to update; may span multiple entity types.
+ * @return the fetched entities in input order.
+ * @throws PersistenceException if an explicit member or a referenced entity is unsaved, or if the update fails.
+ */
+ @Nonnull
+ List> updateAndFetch(@Nonnull Iterable extends Entity>> entities);
+
+ /**
+ * Upserts the explicit members and inserts their insertion closure, in dependency order.
+ *
+ * Explicit members are upserted with the exact semantics of the per-repository upsert (native
+ * {@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.
+ *
+ * @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
+ * is referenced through a non-insertable foreign key component, or if the upsert fails.
+ */
+ void upsert(@Nonnull Iterable extends Entity>> entities);
+
+ /**
+ * Upserts like {@link #upsert(Iterable)} and returns the passed entities as they exist in the database after the
+ * upsert, in input order.
+ *
+ * @param entities the entities to upsert; may span multiple entity types.
+ * @return the fetched entities in input order.
+ * @throws PersistenceException if the upsert fails.
+ */
+ @Nonnull
+ List> upsertAndFetch(@Nonnull Iterable extends Entity>> entities);
+
+ /**
+ * Removes the given entities, children before parents.
+ *
+ * Only the explicit members are removed; referenced entities are never removed implicitly. Dependencies
+ * between set members are resolved by entity type and primary key (not instance identity), so a member
+ * referencing another member through a foreign key is removed first, regardless of whether the two hold the same
+ * instance. Unsaved entities are rejected.
+ *
+ * @param entities the entities to remove; may span multiple entity types.
+ * @throws PersistenceException if a passed entity is unsaved, or if the removal fails.
+ */
+ void remove(@Nonnull Iterable extends Entity>> entities);
+
+ //
+ // Single-root convenience variants.
+ //
+
+ /**
+ * Inserts the given entity and its insertion closure; see {@link #insert(Iterable)}.
+ *
+ * @param entity the root entity to insert.
+ * @throws PersistenceException if the insert fails.
+ */
+ default void insert(@Nonnull Entity> entity) {
+ insert(List.of(entity));
+ }
+
+ /**
+ * Inserts the given entity and its insertion closure, and returns the entity as it exists in the database after
+ * the insert; see {@link #insertAndFetch(Iterable)}.
+ *
+ * @param entity the root entity to insert.
+ * @param the entity type.
+ * @return the fetched entity, with generated keys, defaults and version columns reflected and discovered members
+ * hydrated.
+ * @throws PersistenceException if the insert fails.
+ */
+ @SuppressWarnings("unchecked")
+ @Nonnull
+ default > E insertAndFetch(@Nonnull E entity) {
+ return (E) insertAndFetch(List.of(entity)).getFirst();
+ }
+
+ /**
+ * Updates the given entity; see {@link #update(Iterable)}.
+ *
+ * @param entity the entity to update.
+ * @throws PersistenceException if the entity is unsaved or the update fails.
+ */
+ default void update(@Nonnull Entity> entity) {
+ update(List.of(entity));
+ }
+
+ /**
+ * Updates the given entity and returns it as it exists in the database after the update; see
+ * {@link #updateAndFetch(Iterable)}.
+ *
+ * @param entity the entity to update.
+ * @param the entity type.
+ * @return the fetched entity.
+ * @throws PersistenceException if the entity is unsaved or the update fails.
+ */
+ @SuppressWarnings("unchecked")
+ @Nonnull
+ default > E updateAndFetch(@Nonnull E entity) {
+ return (E) updateAndFetch(List.of(entity)).getFirst();
+ }
+
+ /**
+ * Upserts the given entity and inserts its insertion closure; see {@link #upsert(Iterable)}.
+ *
+ * @param entity the root entity to upsert.
+ * @throws PersistenceException if the upsert fails.
+ */
+ default void upsert(@Nonnull Entity> entity) {
+ upsert(List.of(entity));
+ }
+
+ /**
+ * Upserts the given entity, inserts its insertion closure, and returns the entity as it exists in the database
+ * after the upsert; see {@link #upsertAndFetch(Iterable)}.
+ *
+ * @param entity the root entity to upsert.
+ * @param the entity type.
+ * @return the fetched entity.
+ * @throws PersistenceException if the upsert fails.
+ */
+ @SuppressWarnings("unchecked")
+ @Nonnull
+ default > E upsertAndFetch(@Nonnull E entity) {
+ return (E) upsertAndFetch(List.of(entity)).getFirst();
+ }
+
+ /**
+ * Removes the given entity; see {@link #remove(Iterable)}.
+ *
+ * @param entity the entity to remove.
+ * @throws PersistenceException if the entity is unsaved or the removal fails.
+ */
+ default void remove(@Nonnull Entity> entity) {
+ remove(List.of(entity));
+ }
+}
diff --git a/storm-h2/src/test/java/st/orm/spi/h2/H2WriteSetTest.java b/storm-h2/src/test/java/st/orm/spi/h2/H2WriteSetTest.java
new file mode 100644
index 000000000..e83be897a
--- /dev/null
+++ b/storm-h2/src/test/java/st/orm/spi/h2/H2WriteSetTest.java
@@ -0,0 +1,142 @@
+package st.orm.spi.h2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static st.orm.GenerationStrategy.SEQUENCE;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import javax.sql.DataSource;
+import lombok.Builder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import st.orm.DbTable;
+import st.orm.Entity;
+import st.orm.FK;
+import st.orm.PK;
+import st.orm.Version;
+import st.orm.core.template.ORMTemplate;
+import st.orm.core.template.SqlInterceptor;
+
+/**
+ * Write-set tests that require a dialect with native upsert support; the H2 dialect maps upsert to {@code MERGE}.
+ * Also covers generated-key propagation for sequence-based primary keys.
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = IntegrationConfig.class)
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@DataJpaTest(showSql = false)
+public class H2WriteSetTest {
+
+ @Autowired
+ private DataSource dataSource;
+
+ @Builder(toBuilder = true)
+ public record Owner(
+ @PK Integer id,
+ @Nonnull String firstName,
+ @Nonnull String lastName,
+ @Nullable String telephone,
+ @Version int version
+ ) implements Entity {}
+
+ @Builder(toBuilder = true)
+ public record PetType(
+ @PK Integer id,
+ @Nonnull String name
+ ) implements Entity {}
+
+ @Builder(toBuilder = true)
+ @DbTable("pet")
+ public record Pet(
+ @PK(generation = SEQUENCE, sequence = "pet_id_seq") Integer id,
+ @Nonnull String name,
+ @Nonnull LocalDate birthDate,
+ @Nonnull @FK PetType type,
+ @Nullable @FK Owner owner
+ ) implements Entity {}
+
+ @Test
+ public void testInsertGraphPropagatesKeysToSequenceBasedLeaf() {
+ // The pet's own sequence-generated key is consumed by nobody, so the write set inserts it without fetch
+ // mode, which H2 does not support for sequence-based keys. The identity-based owner and pet type keys are
+ // consumed by the pet and are fetched and propagated.
+ var orm = ORMTemplate.of(dataSource);
+ var owner = Owner.builder().firstName("Seq").lastName("Owner").build();
+ var type = PetType.builder().name("writeSetDog").build();
+ var pet = Pet.builder().name("SeqPet").birthDate(LocalDate.of(2024, 4, 4)).type(type).owner(owner).build();
+ orm.writeSet().insert(List.of(pet));
+ var fetched = orm.entity(Pet.class).select().getResultList().stream()
+ .filter(candidate -> "SeqPet".equals(candidate.name()))
+ .toList();
+ assertEquals(1, fetched.size());
+ assertNotEquals(0, fetched.getFirst().id());
+ assertNotNull(fetched.getFirst().owner());
+ assertNotEquals(0, fetched.getFirst().owner().id());
+ assertEquals("Seq", fetched.getFirst().owner().firstName());
+ assertEquals("writeSetDog", fetched.getFirst().type().name());
+ }
+
+ @Test
+ public void testUpsertWritesExplicitKeyedParentBeforeReferencingChild() {
+ // Explicit membership takes precedence over being referenced: the keyed pet type is upserted, not
+ // bind-only, and it is written before the pet that references it, even though the pet appears first in
+ // the list. Upsert preserves the member's key, so the ordering applies to auto-generated keys as well.
+ var orm = ORMTemplate.of(dataSource);
+ var existingType = orm.entity(PetType.class).insertAndFetch(PetType.builder().name("orderedType").build());
+ var renamedType = existingType.toBuilder().name("orderedTypeRenamed").build();
+ var pet = Pet.builder().name("OrderedPet").birthDate(LocalDate.of(2022, 2, 2)).type(renamedType).build();
+ List statements = new ArrayList<>();
+ SqlInterceptor.observe(
+ sql -> statements.add(sql.statement().toLowerCase()),
+ () -> { orm.writeSet().upsert(List.of(pet, renamedType)); return null; });
+ assertEquals("orderedTypeRenamed", orm.entity(PetType.class).getById(existingType.id()).name());
+ var fetched = orm.entity(Pet.class).select().getResultList().stream()
+ .filter(candidate -> "OrderedPet".equals(candidate.name()))
+ .toList();
+ assertEquals(1, fetched.size());
+ assertEquals(existingType.id(), fetched.getFirst().type().id());
+ var petTypeIndex = indexOfFirst(statements, statement -> statement.contains("pet_type"));
+ var petIndex = indexOfFirst(statements, statement -> statement.contains("into pet ") || statement.contains("into pet\n"));
+ assertTrue(petTypeIndex >= 0 && petIndex >= 0, String.join("\n", statements));
+ assertTrue(petTypeIndex < petIndex, String.join("\n", statements));
+ }
+
+ private static int indexOfFirst(List statements, java.util.function.Predicate predicate) {
+ for (int i = 0; i < statements.size(); i++) {
+ if (predicate.test(statements.get(i))) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ @Test
+ public void testUpsertMixesInsertAndUpdateBranches() {
+ var orm = ORMTemplate.of(dataSource);
+ var dogType = orm.entity(PetType.class).insertAndFetch(PetType.builder().name("writeSetDog").build());
+ var newOwner = Owner.builder().firstName("Upsert").lastName("Fresh").build();
+ var pet = Pet.builder().name("Upserted").birthDate(LocalDate.of(2019, 9, 9)).type(dogType).owner(newOwner).build();
+ // One call: the keyed pet type takes the update branch, the pet takes the insert branch, and the unsaved
+ // owner joins via the insertion closure and is inserted first.
+ orm.writeSet().upsert(List.of(dogType.toBuilder().name("writeSetDoggo").build(), pet));
+ assertEquals("writeSetDoggo", orm.entity(PetType.class).getById(dogType.id()).name());
+ var fetched = orm.entity(Pet.class).select().getResultList().stream()
+ .filter(candidate -> "Upserted".equals(candidate.name()))
+ .toList();
+ assertEquals(1, fetched.size());
+ assertNotNull(fetched.getFirst().owner());
+ assertEquals("Upsert", fetched.getFirst().owner().firstName());
+ assertEquals(dogType.id(), fetched.getFirst().type().id());
+ }
+}
diff --git a/storm-java21/src/main/java/st/orm/repository/Repository.java b/storm-java21/src/main/java/st/orm/repository/Repository.java
index d98e64470..8eadc6e9a 100644
--- a/storm-java21/src/main/java/st/orm/repository/Repository.java
+++ b/storm-java21/src/main/java/st/orm/repository/Repository.java
@@ -15,6 +15,7 @@
*/
package st.orm.repository;
+import st.orm.WriteSet;
import st.orm.template.ORMTemplate;
/**
@@ -35,4 +36,19 @@ public interface Repository {
* @return the ORM template.
*/
ORMTemplate orm();
+
+ /**
+ * Returns dependency-aware write operations over mixed-type sets of entities.
+ *
+ * The write set belongs to the underlying ORM template and is not scoped to this repository's entity type;
+ * it accepts entities of any type. This accessor is a convenience for repository methods, equivalent to
+ * {@code orm().writeSet()}.
+ *
+ * @return the write set operations of the underlying ORM template.
+ * @see WriteSet
+ * @since 1.13
+ */
+ default WriteSet writeSet() {
+ return orm().writeSet();
+ }
}
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 adb15f1ff..6fd7d5cec 100644
--- a/storm-java21/src/main/java/st/orm/repository/RepositoryLookup.java
+++ b/storm-java21/src/main/java/st/orm/repository/RepositoryLookup.java
@@ -18,6 +18,7 @@
import jakarta.annotation.Nonnull;
import st.orm.Entity;
import st.orm.Projection;
+import st.orm.WriteSet;
/**
* Provides access to repositories.
@@ -76,4 +77,17 @@ public interface RepositoryLookup {
* @return a proxy for the repository of the given type.
*/
R repository(@Nonnull Class type);
+
+ /**
+ * 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
+ * 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.
+ *
+ * @return the write set operations bound to this lookup.
+ * @see WriteSet
+ * @since 1.13
+ */
+ WriteSet writeSet();
}
diff --git a/storm-java21/src/main/java/st/orm/template/impl/ORMTemplateImpl.java b/storm-java21/src/main/java/st/orm/template/impl/ORMTemplateImpl.java
index 6f77ace02..3b199df64 100644
--- a/storm-java21/src/main/java/st/orm/template/impl/ORMTemplateImpl.java
+++ b/storm-java21/src/main/java/st/orm/template/impl/ORMTemplateImpl.java
@@ -31,6 +31,7 @@
import st.orm.Entity;
import st.orm.EntityCallback;
import st.orm.Projection;
+import st.orm.WriteSet;
import st.orm.core.spi.ORMReflection;
import st.orm.core.spi.Providers;
import st.orm.core.template.impl.SqlLogInterceptor;
@@ -90,6 +91,11 @@ public void validateSchemaOrThrow(@Nonnull Iterable> types
core.validateSchemaOrThrow(types);
}
+ @Override
+ public WriteSet writeSet() {
+ return core.writeSet();
+ }
+
/**
* Returns the repository for the given entity type.
*
diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/Repository.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/Repository.kt
index 0c5e7fc7c..ec0f8d779 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/repository/Repository.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/repository/Repository.kt
@@ -15,6 +15,7 @@
*/
package st.orm.repository
+import st.orm.WriteSet
import st.orm.template.ORMTemplate
/**
@@ -34,4 +35,17 @@ interface Repository {
* @return the ORM template.
*/
val orm: ORMTemplate
+
+ /**
+ * Returns dependency-aware write operations over mixed-type sets of entities.
+ *
+ * The write set belongs to the underlying ORM template and is not scoped to this repository's entity type;
+ * it accepts entities of any type. This accessor is a convenience for repository methods, equivalent to
+ * `orm.writeSet()`.
+ *
+ * @return the write set operations of the underlying ORM template.
+ * @see WriteSet
+ * @since 1.13
+ */
+ fun writeSet(): WriteSet = orm.writeSet()
}
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 f2a5ab162..3c1af87c1 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt
@@ -85,10 +85,59 @@ interface RepositoryLookup {
* @return a proxy for the repository of the given type.
*/
fun repository(type: KClass): R
+
+ /**
+ * 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
+ * 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.
+ *
+ * ```kotlin
+ * val owner = Owner(name = "Alice", address = address) // unsaved
+ * val wolfie = Pet(name = "Wolfie", type = dog, owner = owner)
+ * val visit = Visit(visitDate = today, description = "Check-up", pet = wolfie)
+ * orm.writeSet().insert(listOf(wolfie, visit)) // owner joins via the insertion closure
+ * ```
+ *
+ * @return the write set operations bound to this lookup.
+ * @see WriteSet
+ * @since 1.13
+ */
+ fun writeSet(): WriteSet
}
// Kotlin specific DSL
+/**
+ * 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
+ * them. Combine with `transaction { }` when atomicity across the calls is required:
+ *
+ * ```kotlin
+ * transaction {
+ * orm.writeSet {
+ * insert(pets + visits)
+ * update(changedOwners)
+ * remove(staleVisits)
+ * }
+ * }
+ * ```
+ *
+ * @since 1.13
+ */
+inline fun RepositoryLookup.writeSet(block: WriteSet.() -> R): R = writeSet().block()
+
+/**
+ * Runs [block] against the underlying ORM template's [WriteSet]; see [RepositoryLookup.writeSet].
+ *
+ * Convenience for repository methods; each verb inside the block executes immediately.
+ *
+ * @since 1.13
+ */
+inline fun Repository.writeSet(block: WriteSet.() -> R): R = writeSet().block()
+
/**
* Returns the repository for entity type [T] with primary key type [ID].
*
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt
index 8b3edfa37..e89b0df9d 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ORMTemplateImpl.kt
@@ -19,6 +19,7 @@ import st.orm.Data
import st.orm.Entity
import st.orm.EntityCallback
import st.orm.Projection
+import st.orm.WriteSet
import st.orm.core.spi.ORMReflection
import st.orm.core.spi.Providers
import st.orm.core.template.impl.SqlLogInterceptor
@@ -98,6 +99,8 @@ class ORMTemplateImpl(private val core: st.orm.core.template.ORMTemplate) :
override fun validateSchemaOrThrow(vararg types: KClass) = core.validateSchemaOrThrow(types.map { it.java })
+ override fun writeSet(): WriteSet = core.writeSet()
+
/**
* Returns the repository for the given entity type.
*
diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt
new file mode 100644
index 000000000..5b1465c3e
--- /dev/null
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt
@@ -0,0 +1,114 @@
+package st.orm.template
+
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.nulls.shouldNotBeNull
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
+import io.kotest.matchers.string.shouldContain
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.test.context.ContextConfiguration
+import org.springframework.test.context.jdbc.Sql
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import st.orm.PersistenceException
+import st.orm.repository.entity
+import st.orm.repository.writeSet
+import st.orm.template.model.Address
+import st.orm.template.model.City
+import st.orm.template.model.Owner
+import st.orm.template.model.Pet
+import st.orm.template.model.PetType
+import st.orm.template.model.Visit
+import java.time.Instant
+import java.time.LocalDate
+
+@ExtendWith(SpringExtension::class)
+@ContextConfiguration(classes = [IntegrationConfig::class])
+@Sql("/data.sql")
+open class WriteSetTest(
+ @Autowired val orm: ORMTemplate,
+) {
+
+ private fun newOwner(firstName: String) = Owner(
+ firstName = firstName,
+ lastName = "WriteSet",
+ address = Address("1 Graph Road", City(id = 1, name = "Sun Paririe")),
+ telephone = null,
+ version = 0,
+ )
+
+ private val dog = PetType(id = 1, name = "dog")
+
+ @Test
+ fun `insert should persist a data class graph with a shared unsaved parent`() {
+ val owner = newOwner("Alice")
+ val wolfie = Pet(name = "Wolfie", birthDate = LocalDate.of(2024, 1, 1), type = dog, owner = owner)
+ val rex = Pet(name = "Rex", birthDate = LocalDate.of(2024, 2, 2), type = dog, owner = owner)
+ val visit = Visit(visitDate = LocalDate.of(2026, 7, 14), description = "Check-up", pet = wolfie, timestamp = Instant.now())
+ orm.writeSet().insert(listOf(wolfie, rex, visit))
+ val owners = orm.entity().findAll().filter { it.firstName == "Alice" }
+ owners shouldHaveSize 1
+ val pets = orm.entity().findAll().filter { it.owner?.id == owners.single().id }
+ pets shouldHaveSize 2
+ val fetchedVisit = orm.entity().findAll().single { it.description == "Check-up" }
+ fetchedVisit.pet.name shouldBe "Wolfie"
+ fetchedVisit.pet.owner.shouldNotBeNull().firstName shouldBe "Alice"
+ }
+
+ @Test
+ fun `insertAndFetch should pull unsaved city through the inline address component`() {
+ val owner = newOwner("Inline").copy(address = Address("2 Closure Lane", City(name = "Graphville")))
+ val fetched = orm.writeSet().insertAndFetch(listOf(owner))
+ fetched shouldHaveSize 1
+ val fetchedOwner = fetched.single() as Owner
+ fetchedOwner.address.city.shouldNotBeNull().name shouldBe "Graphville"
+ fetchedOwner.address.city.shouldNotBeNull().id shouldNotBe 0
+ }
+
+ @Test
+ fun `writeSet block should scope write-set calls with typed single-root variants`() {
+ val owner = newOwner("Block")
+ val pet = Pet(name = "BlockPet", birthDate = LocalDate.of(2024, 8, 8), type = dog, owner = owner)
+ val visit: Visit = orm.writeSet {
+ val inserted = insertAndFetch(
+ Visit(visitDate = LocalDate.of(2026, 5, 5), description = "Block visit", pet = pet, timestamp = Instant.now()),
+ )
+ update(inserted.copy(description = "Block visit updated"))
+ inserted
+ }
+ orm.entity().findAll().single { it.id == visit.id }.description shouldBe "Block visit updated"
+ visit.pet.owner.shouldNotBeNull().firstName shouldBe "Block"
+ }
+
+ @Test
+ fun `writeSet should be accessible from a repository`() {
+ val pets = orm.entity()
+ val owner = newOwner("FromRepo")
+ pets.writeSet().insert(listOf(Pet(name = "RepoPet", birthDate = LocalDate.of(2023, 6, 6), type = dog, owner = owner)))
+ val fetched = pets.findAll().single { it.name == "RepoPet" }
+ fetched.owner.shouldNotBeNull().firstName shouldBe "FromRepo"
+ }
+
+ @Test
+ fun `update should reject unsaved entities`() {
+ val exception = assertThrows {
+ orm.writeSet().update(listOf(newOwner("Never")))
+ }
+ exception.message.shouldNotBeNull() shouldContain "unsaved"
+ }
+
+ @Test
+ fun `remove should order children before parents`() {
+ val owner = newOwner("Removable")
+ val pet = Pet(name = "Doomed", birthDate = LocalDate.of(2020, 1, 1), type = dog, owner = owner)
+ val visit = Visit(visitDate = LocalDate.of(2026, 3, 3), description = "Last visit", pet = pet, timestamp = Instant.now())
+ val inserted = orm.writeSet().insertAndFetch(listOf(owner, pet, visit))
+ // Parents first in the argument list; the write set reorders (H2 enforces the FK constraints).
+ orm.writeSet().remove(inserted)
+ orm.entity().findAll().none { it.firstName == "Removable" } shouldBe true
+ orm.entity().findAll().none { it.name == "Doomed" } shouldBe true
+ orm.entity().findAll().none { it.description == "Last visit" } shouldBe true
+ }
+}
diff --git a/website/scripts/generate-llms-full.sh b/website/scripts/generate-llms-full.sh
index 008599985..d305287c4 100755
--- a/website/scripts/generate-llms-full.sh
+++ b/website/scripts/generate-llms-full.sh
@@ -44,6 +44,7 @@ DOCS=(
# Advanced Topics - Operations
batch-streaming.md
upserts.md
+ write-sets.md
# Advanced Topics - Internals
sql-templates.md
string-templates.md
diff --git a/website/sidebars.ts b/website/sidebars.ts
index 7d70dc8e1..23ff2d557 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -45,6 +45,7 @@ const sidebars: SidebarsConfig = {
items: [
'batch-streaming',
'upserts',
+ 'write-sets',
],
},
{
diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt
index f1bcdfd40..c1d553a7f 100644
--- a/website/static/llms-full.txt
+++ b/website/static/llms-full.txt
@@ -18,13 +18,13 @@
> GitHub: https://github.com/storm-orm/storm-framework
> License: Apache 2.0
-# Generated: 2026-07-04T14:18:50Z
+# Generated: 2026-07-14T22:19:49Z
========================================
## Source: index.md
========================================
-# ST/ORM
+# Storm
**Storm** is a modern, high-performance ORM for Kotlin 2.0+ and Java 21+, built around a powerful SQL template engine. It focuses on simplicity, type safety, and predictable performance through immutable models and compile-time metadata.
@@ -272,29 +272,37 @@ Storm is released under the [Apache 2.0 License](https://github.com/storm-repo/s
## Source: getting-started.md
========================================
-# Getting Started
+# Get Started
Storm is a modern SQL Template and ORM framework for Kotlin 2.0+ and Java 21+. It uses immutable data classes and records instead of proxied entities, giving you predictable behavior, type-safe queries, and high performance.
-## Design Philosophy
+## Choose Your Path
-Storm is built around a simple idea: your data model should be a plain value, not a framework-managed object. In Storm, entities are Kotlin data classes or Java records. They carry no hidden state, no change-tracking proxies, and no lazy-loading hooks. You can create them, pass them across layers, serialize them, compare them by value, and store them in collections without worrying about session scope, detachment, or side effects. What you see in the source code is exactly what exists at runtime.
+Two ways to get started, and both reach the same working setup: follow the guides by hand, or let your AI coding tool do it. Pick whichever fits your workflow.
+[Manual]
-This stateless design is a deliberate trade-off. Traditional ORMs like JPA/Hibernate give you transparent lazy loading and proxy-based dirty checking, but at the cost of complexity: you must reason about managed vs. detached state, proxy initialization, persistence context boundaries, and cascading rules that interact in subtle ways. Storm avoids all of this. It still performs dirty checking, but by comparing entity state within a transaction rather than through proxies or bytecode manipulation. When you query a relationship, you get the result in the same query. There are no surprises.
+### Manual Setup
-Storm is also SQL-first. Rather than abstracting SQL away behind a query language (like JPQL) or a verbose criteria builder, Storm embraces SQL directly. Its SQL Template API lets you write real SQL with type-safe parameter interpolation and automatic result mapping. For common CRUD patterns, the type-safe DSL and repository interfaces provide concise, compiler-checked alternatives, but the full power of SQL is always available when you need it.
+Follow these three steps in order for the fastest path from zero to a working application.
-The framework is organized around three core abstractions:
+**1. Installation**
-- **Entity** is your data model. A Kotlin data class or Java record with a few annotations (`@PK`, `@FK`) that describe its mapping to the database. Storm derives table and column names automatically, so annotations are only needed for primary keys, foreign keys, and cases where the naming convention does not match.
-- **Repository** provides CRUD operations and type-safe queries for a specific entity. You define an interface, write query methods with explicit bodies using the DSL, and Storm handles the rest. No magic method-name parsing, no hidden query generation.
-- **SQL Template** gives you direct access to SQL with type-safe parameter binding and result mapping. You write real SQL, embed parameters and entity types directly in the query string, and get back typed results. This is the escape hatch when the DSL is not enough, and it is a first-class citizen in Storm, not an afterthought.
+Set up your project with the right dependencies, build flags, and optional modules.
-These abstractions share a common principle: explicit behavior over implicit magic. Every query is visible in the source code. Every relationship is loaded when you ask for it. Every transaction boundary is declared, not inferred. This makes Storm applications straightforward to debug, profile, and reason about.
+**[Go to Installation](installation.md)**
-## Choose Your Path
+**2. First Entity**
+
+Define your first entity, create an ORM template, and perform insert, read, update, and remove operations.
+
+**[Go to First Entity](first-entity.md)**
+
+**3. First Query**
+
+Write custom queries, build repositories, stream results, and use the type-safe metamodel.
+
+**[Go to First Query](first-query.md)**
-Storm supports two ways to get started. Pick the one that fits your workflow.
[AI-Assisted]
### AI-Assisted Setup
@@ -324,35 +332,11 @@ For example:
Storm's AI workflow includes built-in verification. The AI can run `ORMTemplate.validateSchema()` to prove entities match the database and `SqlCapture` to inspect generated SQL, all in an isolated H2 test database before anything touches production.
See [AI-Assisted Development](ai.md) for the full setup guide, available skills, and MCP server configuration.
-
-[Manual]
-
-### Manual Setup
-
-Follow these three steps in order for the fastest path from zero to a working application.
-
-**1. Installation**
-
-Set up your project with the right dependencies, build flags, and optional modules.
-
-**[Go to Installation](installation.md)**
-
-**2. First Entity**
-
-Define your first entity, create an ORM template, and perform insert, read, update, and remove operations.
-
-**[Go to First Entity](first-entity.md)**
-
-**3. First Query**
-
-Write custom queries, build repositories, stream results, and use the type-safe metamodel.
-
-**[Go to First Query](first-query.md)**
---
## What's Next
-Once you have completed the getting-started guides, explore the features that match your needs:
+Once you have completed the steps above, explore the features that match your needs:
**Core Concepts:**
- [Entities](entities.md) -- annotations, nullability, naming conventions
@@ -402,6 +386,60 @@ This page covers everything you need to add Storm to your project: prerequisites
Kotlin users do not need any preview flags. Java users must enable `--enable-preview` in their compiler configuration because the Java API uses String Templates (JEP 430).
+## Gradle Plugin (Recommended)
+
+The Storm Gradle plugin collapses the whole setup into one plugin application. It imports the BOM, adds the core dependencies for your language, wires the metamodel processor, selects the Kotlin compiler-plugin variant matching your Kotlin version, and configures the Java preview flags. Requires Gradle 8.5+.
+
+[Kotlin]
+
+```kotlin
+plugins {
+ kotlin("jvm") version "2.4.0"
+ id("com.google.devtools.ksp") version "2.3.10"
+ id("st.orm") version "@@STORM_VERSION@@"
+}
+```
+
+That is the entire Storm setup. KSP stays in your plugins block because its version is paired to your Kotlin version; when it is missing, the build fails with the exact line to add.
+
+[Java]
+
+```kotlin
+plugins {
+ java
+ id("st.orm") version "@@STORM_VERSION@@"
+}
+```
+
+That is the entire Storm setup, including the `--enable-preview` flags on compilation, tests, and execution that storm-java21's String Templates (JEP 430) require. Use a JDK 21 toolchain: preview class files are version-locked, so storm-java21 runs on JDK 21 exactly.
+The plugin configures, per language path:
+
+| | Kotlin | Java |
+|---|--------|------|
+| BOM | `storm-bom` imported as a platform | `storm-bom` imported as a platform |
+| API | `storm-kotlin` | `storm-java21` |
+| Engine | `storm-core` (runtime only) | `storm-core` (runtime only) |
+| Metamodel | `storm-metamodel-ksp` on `ksp` | `storm-metamodel-processor` on `annotationProcessor` |
+| Compiler plugin | `storm-compiler-plugin-` matching the Kotlin version | — |
+| Compiler flags | — | `--enable-preview` on compile, test, and exec tasks |
+
+All Storm coordinates use the plugin's own version; the plugin and the artifacts are released together. Because the BOM is imported, optional modules stay version-less: `runtimeOnly("st.orm:storm-postgresql")` just works.
+
+The `storm { }` extension covers the cases where the defaults do not fit:
+
+```kotlin
+storm {
+ metamodel.set(true) // metamodel generation (default true)
+ compilerPlugin.set(true) // Kotlin: Storm compiler plugin (default true)
+ compilerPluginVariant.set("2.4") // pin the variant, e.g. for a newer Kotlin than the plugin knows
+ javaPreview.set(true) // Java: --enable-preview flags (default true)
+}
+```
+
+In mixed Kotlin/Java projects the Kotlin path wins: KSP processes Java declarations too. If you specifically need the Java annotation processor as well, add `annotationProcessor("st.orm:storm-metamodel-processor")` manually.
+
+Maven users and Gradle users who prefer explicit configuration continue with the manual setup below.
+
## Add the BOM
Storm provides a Bill of Materials (BOM) for centralized version management. Import the BOM once, then omit version numbers from individual Storm dependencies. This prevents version mismatches between modules.
@@ -587,6 +625,23 @@ Storm supports storing and reading JSON-typed columns. Pick the module that matc
See [JSON Support](json.md) for usage details.
+### Observability
+
+| Module | Provides |
+|--------|----------|
+| `storm-micrometer` | Micrometer Observations for queries and transactions (`storm.query`, `storm.transaction`), the OpenTelemetry database semantic conventions, and trace-context SQL comments |
+
+The Spring Boot starters include `storm-micrometer`; Ktor applications add it explicitly. See the observability sections of [Spring Integration](spring-integration.md#observability) and [Ktor Integration](ktor-integration.md#observability).
+
+### Testing
+
+| Module | Provides |
+|--------|----------|
+| `storm-test` | `@StormTest` JUnit 5 extension and `SqlCapture`, framework-free |
+| `storm-spring-boot-test-autoconfigure` | The `@DataStormTest` Spring Boot test slice (test scope) |
+
+See [Testing](testing.md) and [Testing with @DataStormTest](spring-integration.md#testing-with-datastormtest).
+
## Module Overview
The following diagram shows how Storm's modules relate to each other. You only need the modules relevant to your language and integration choices.
@@ -657,7 +712,7 @@ record User(@PK Integer id,
) implements Entity {}
```
-In Java, record components are nullable by default. Use `@Nonnull` on fields that must always have a value. Primitive types (`int`, `long`, etc.) are inherently non-nullable.
+In Java, record components are non-null by default, exactly like Kotlin. Mark nullable fields with `@Nullable` (JSpecify's `org.jspecify.annotations.Nullable` or `jakarta.annotation.Nullable`); see [Defining Entities](entities.md) for the full nullability rules.
The `@Builder` annotation is from [Lombok](https://projectlombok.org/) and is optional. It generates a builder that lets you construct entities without specifying the primary key, and creates modified copies via `toBuilder()`. Without Lombok, you can pass `null` as the primary key (e.g., `new City(null, "Sunnyvale", 161_884)`) or define a convenience constructor that omits it. See [Modifying Entities](entities.md#modifying-entities) for details.
These entities map to the following database tables:
@@ -925,7 +980,7 @@ interface UserRepository : EntityRepository {
findAll((User_.city eq city) and (User_.name eq name))
fun streamByCity(city: City): Flow =
- select { User_.city eq city }
+ select(User_.city eq city).resultFlow
}
// Get the repository from the ORM template
@@ -1155,7 +1210,7 @@ record User(@PK Integer id,
String email,
LocalDate birthDate,
String street,
- String postalCode,
+ @Nullable String postalCode,
@FK City city
) implements Entity {}
```
@@ -1189,13 +1244,13 @@ data class User(
[Java]
-In Java, record components are nullable by default. Use `@Nonnull` to mark fields that must always have a value — Storm recognizes `jakarta.annotation.Nonnull`, `javax.annotation.Nonnull`, and JSpecify's `org.jspecify.annotations.NonNull` on the component; other null-marker annotations (Lombok, JetBrains, Spring) are not recognized, and JSpecify `@NullMarked` scope defaults are not applied. `@PK` fields and primitive types (`int`, `long`, etc.) are inherently non-nullable. As with Kotlin, nullability determines JOIN behavior: a non-nullable `@FK` field produces an `INNER JOIN`, while a nullable one — including a bare `@FK` field without `@Nonnull` — produces a `LEFT JOIN`. If the FK column is `NOT NULL` in the database, annotate the field with `@Nonnull`, or joins silently degrade to `LEFT JOIN`.
+In Java, record components are non-null by default, exactly like Kotlin: `String` means a value is always present, and nullable is the marked case. Mark nullable fields with `@Nullable` — Storm recognizes JSpecify's `org.jspecify.annotations.Nullable` (as a type-use annotation), `jakarta.annotation.Nullable`, and `javax.annotation.Nullable`. This matches JSpecify's `@NullMarked` semantics: annotating your model package `@NullMarked` is welcome documentation for static checkers, and `@NullUnmarked` on a class or package opts back into lenient, nullable-by-default components for that scope. As with Kotlin, nullability determines JOIN behavior: a non-nullable `@FK` field — including a bare, unannotated one — produces an `INNER JOIN`, while a `@Nullable @FK` field produces a `LEFT JOIN`. If the FK column allows `NULL` in the database, annotate the field `@Nullable`, or rows without the reference are silently filtered by the inner join.
```java
record User(@PK Integer id,
- @Nonnull String email, // Non-nullable
- @Nonnull LocalDate birthDate, // Non-nullable
- String postalCode, // Nullable (default)
+ String email, // Non-nullable (default)
+ LocalDate birthDate, // Non-nullable (default)
+ @Nullable String postalCode, // Nullable
@Nullable @FK City city // Nullable (results in LEFT JOIN)
) implements Entity {}
```
@@ -1260,7 +1315,7 @@ Use `NONE` when:
```java
record User(@PK Integer id, // Database generates via auto-increment
- @Nonnull String name
+ String name
) implements Entity {}
```
@@ -1275,7 +1330,7 @@ var inserted = orm.entity(User.class).insert(user); // Returns User with genera
```java
record Order(@PK(generation = SEQUENCE, sequence = "order_seq") Long id,
- @Nonnull BigDecimal total
+ BigDecimal total
) implements Entity {}
```
@@ -1285,7 +1340,7 @@ Storm fetches the next value from the sequence before inserting.
```java
record Country(@PK(generation = NONE) String code, // Caller provides the value
- @Nonnull String name
+ String name
) implements Entity {}
```
@@ -1320,8 +1375,8 @@ data class UserRole(
record UserRolePk(int userId, int roleId) {}
record UserRole(@PK UserRolePk userRolePk,
- @Nonnull @FK User user,
- @Nonnull @FK Role role
+ @FK User user,
+ @FK Role role
) implements Entity {}
```
---
@@ -1402,8 +1457,8 @@ data class SomeEntity(
record UserEmailUk(int userId, String email) {}
record SomeEntity(@PK Integer id,
- @Nonnull @FK User user,
- @Nonnull String email,
+ @FK User user,
+ String email,
@UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey
) implements Entity {}
```
@@ -1443,13 +1498,13 @@ data class Owner(
Use records for embedded components:
```java
-record Address(String street,
- @FK City city) {}
+record Address(@Nullable String street,
+ @Nullable @FK City city) {}
record Owner(@PK Integer id,
- @Nonnull String firstName,
- @Nonnull String lastName,
- @Nonnull Address address,
+ String firstName,
+ String lastName,
+ Address address,
@Nullable String telephone
) implements Entity {}
```
@@ -1479,9 +1534,9 @@ In this example, the `owner` and `city` foreign keys define the actual persisted
record OwnerCityKey(int ownerId, int cityId) {}
record Pet(@PK Integer id,
- @Nonnull String name,
- @Nonnull @FK Owner owner,
- @Nonnull @FK City city,
+ String name,
+ @FK Owner owner,
+ @FK City city,
@Persist(insertable = false, updatable = false) OwnerCityKey ownerCityKey
) implements Entity {}
```
@@ -1531,8 +1586,8 @@ enum RoleType {
}
record Role(@PK Integer id,
- @Nonnull String name,
- @Nonnull RoleType type // Stored as "USER" or "ADMIN"
+ String name,
+ RoleType type // Stored as "USER" or "ADMIN"
) implements Entity {}
```
@@ -1540,8 +1595,8 @@ To store by ordinal:
```java
record Role(@PK Integer id,
- @Nonnull String name,
- @Nonnull @DbEnum(ORDINAL) RoleType type // Stored as 0 or 1
+ String name,
+ @DbEnum(ORDINAL) RoleType type // Stored as 0 or 1
) implements Entity {}
```
---
@@ -1570,7 +1625,7 @@ record Money(BigDecimal amount) {}
@DbTable("product")
record Product(@PK Integer id,
- @Nonnull String name,
+ String name,
@Convert(converter = MoneyConverter.class) Money price
) implements Entity {}
```
@@ -1613,8 +1668,8 @@ Use `@Version` for optimistic locking:
```java
record Owner(@PK Integer id,
- @Nonnull String firstName,
- @Nonnull String lastName,
+ String firstName,
+ String lastName,
@Version int version
) implements Entity {}
```
@@ -1623,10 +1678,10 @@ Timestamps are also supported:
```java
record Visit(@PK Integer id,
- @Nonnull LocalDate visitDate,
+ LocalDate visitDate,
@Nullable String description,
- @Nonnull @FK Pet pet,
- @Version Instant timestamp
+ @FK Pet pet,
+ @Nullable @Version Instant timestamp
) implements Entity {}
```
---
@@ -1655,9 +1710,9 @@ Use `@Persist(updatable = false)` for fields that should only be set on insert:
```java
record Pet(@PK Integer id,
- @Nonnull String name,
- @Nonnull @Persist(updatable = false) LocalDate birthDate,
- @Nonnull @FK @Persist(updatable = false) PetType type,
+ String name,
+ @Persist(updatable = false) LocalDate birthDate,
+ @FK @Persist(updatable = false) PetType type,
@Nullable @FK Owner owner
) implements Entity {}
```
@@ -1670,8 +1725,8 @@ Since Storm entities are immutable, updating a field means creating a new instan
```java
@Builder(toBuilder = true)
record User(@PK Integer id,
- @Nonnull String email,
- @Nonnull String name,
+ String email,
+ String name,
@FK City city
) implements Entity {}
```
@@ -1820,14 +1875,14 @@ data class User(
// Suppress all schema validation for a legacy entity.
@DbIgnore
record LegacyUser(@PK Integer id,
- @Nonnull String name
+ String name
) implements Entity {}
// Suppress schema validation for a specific field.
record User(@PK Integer id,
- @Nonnull String name,
+ String name,
@DbIgnore("DB uses FLOAT, but column only stores whole numbers")
- @Nonnull Integer age
+ Integer age
) implements Entity {}
```
The optional `value` parameter documents why the mismatch is acceptable. When placed on an embedded component field, `@DbIgnore` suppresses validation for all columns within that component.
@@ -1893,8 +1948,8 @@ data class OwnerView(
@DbTable("owner")
record OwnerView(
@PK Integer id,
- @Nonnull String firstName,
- @Nonnull String lastName,
+ String firstName,
+ String lastName,
@Nullable String telephone
) implements Projection {}
```
@@ -1918,9 +1973,9 @@ data class VisitSummary(
```java
record VisitSummary(
- @Nonnull LocalDate visitDate,
+ LocalDate visitDate,
@Nullable String description,
- @Nonnull String petName
+ String petName
) implements Projection {}
```
This projection reads from a `visit_summary` view, following the default class name to table name conversion.
@@ -1945,7 +2000,7 @@ data class PetView(
```java
@DbTable("pet")
record PetView(@PK Integer id,
- @Nonnull String name,
+ String name,
@FK OwnerView owner // References another projection
) implements Projection {}
```
@@ -2204,10 +2259,10 @@ data class OwnerDetail(
```java
// Full entity for writes
record Owner(@PK Integer id,
- @Nonnull String firstName,
- @Nonnull String lastName,
- @Nonnull String address,
- @Nonnull String city,
+ String firstName,
+ String lastName,
+ String address,
+ String city,
@Nullable String telephone,
@Version int version
) implements Entity {}
@@ -2215,17 +2270,17 @@ record Owner(@PK Integer id,
// Lightweight projection for list views
@DbTable("owner")
record OwnerListItem(@PK Integer id,
- @Nonnull String firstName,
- @Nonnull String lastName
+ String firstName,
+ String lastName
) implements Projection {}
// Detailed projection for detail views
@DbTable("owner")
record OwnerDetail(@PK Integer id,
- @Nonnull String firstName,
- @Nonnull String lastName,
- @Nonnull String address,
- @Nonnull String city,
+ String firstName,
+ String lastName,
+ String address,
+ String city,
@Nullable String telephone
) implements Projection {}
```
@@ -2413,7 +2468,7 @@ This fetches pet details with a lightweight owner summary in a single query.
========================================
# Relationships
-Automatic relationship loading is a core part of Storm's design. Your data model is fully captured by immutable entity classes. When you define a foreign key, Storm automatically joins the related entity and returns complete, fully populated records in a single query.
+Automatic relationship loading is a core part of Storm's design. The database owns your data model, and your entities capture it as immutable classes. When you define a foreign key, Storm automatically joins the related entity and returns complete, fully populated records in a single query.
This design enables:
@@ -2546,6 +2601,17 @@ Storm does not store collections on the "one" side of a relationship. Instead, q
val usersInCity: List = orm.findAll(User_.city eq city)
```
+To load parents with their children, group the query by the parent path. The same select is executed; the
+results are grouped during hydration:
+
+```kotlin
+// Load cities with their users in one query
+val usersByCity: Map> = orm.entity()
+ .select()
+ .orderBy(User_.city)
+ .resultGroupedBy(User_.city)
+```
+
[Java]
```java
@@ -2555,6 +2621,25 @@ List usersInCity = orm.entity(User.class)
.where(User_.city, EQUALS, city)
.getResultList();
```
+
+To load parents with their children, group the query by the parent path. The same select is executed; the
+results are grouped during hydration:
+
+```java
+// Load cities with their users in one query
+Map> usersByCity = orm.entity(User.class)
+ .select()
+ .orderBy(User_.city)
+ .getResultGroupedBy(User_.city);
+```
+The grouped terminal returns an unmodifiable, insertion-ordered map: parents appear in the order
+their first row is encountered, children in row order within each parent. Because duplicate entities within a
+result set share the same instance, each child's reference to its parent is the map key itself, and repeated
+parents are materialized once rather than once per row. The path must resolve to a non-null record for every
+result; narrow queries over nullable foreign keys with a `where()` clause first. This replaces the manual
+pattern of querying the many side and grouping in memory, and it loads the whole graph in a single query,
+without the N+1 queries or the join duplication handling that collection-based ORMs need.
+
---
## Many-to-Many
@@ -2588,6 +2673,13 @@ val roles: List = userRoles.map { it.role }
// Find all users with a specific role
val userRoles: List = orm.findAll(UserRole_.role eq role)
val users: List = userRoles.map { it.user }
+
+// Find roles for many users at once, grouped per user
+val rolesByUser: Map> = orm.entity()
+ .select()
+ .where(UserRole_.user inList users)
+ .resultGroupedBy(UserRole_.user)
+ .mapValues { (_, userRoles) -> userRoles.map { it.role } }
```
For more control, use explicit join queries:
@@ -2606,8 +2698,8 @@ val roles: List = orm.entity()
record UserRolePk(int userId, int roleId) {}
record UserRole(@PK UserRolePk userRolePk,
- @Nonnull @FK @Persist(insertable = false, updatable = false) User user,
- @Nonnull @FK @Persist(insertable = false, updatable = false) Role role
+ @FK @Persist(insertable = false, updatable = false) User user,
+ @FK @Persist(insertable = false, updatable = false) Role role
) implements Entity {}
```
@@ -2625,6 +2717,12 @@ List userRoles = orm.entity(UserRole.class)
List roles = userRoles.stream()
.map(UserRole::role)
.toList();
+
+// Find roles for many users at once, grouped per user
+Map> rolesByUser = orm.entity(UserRole.class)
+ .select()
+ .where(UserRole_.user, IN, users)
+ .getResultGroupedBy(UserRole_.user);
```
For more control, use explicit join queries:
@@ -2691,8 +2789,8 @@ data class AuditLog(
record UserRolePk(int userId, int roleId) {}
record UserRole(@PK UserRolePk pk,
- @Nonnull @FK User user,
- @Nonnull @FK Role role,
+ @FK User user,
+ @FK Role role,
Instant grantedAt
) implements Entity {}
@@ -3497,8 +3595,8 @@ Storm repositories are plain interfaces, so Spring cannot discover them through
@Configuration
class AcmeRepositoryBeanFactoryPostProcessor : RepositoryBeanFactoryPostProcessor() {
- override val repositoryBasePackages: Array
- get() = arrayOf("com.acme.repository")
+ override fun getRepositoryBasePackages(): Array =
+ arrayOf("com.acme.repository")
}
```
@@ -3543,11 +3641,11 @@ Storm offers three ways to query data, each suited to different complexity level
| Approach | Best for | Type safety | Flexibility |
|----------|----------|-------------|-------------|
-| **Repository `findBy`** | Simple key lookups by primary key or unique key | Full compile-time | Low (single-field equality only) |
+| **Repository `findBy` / `findAllBy`** | Lookups by primary key or any single field | Full compile-time | Low (single-field equality or `IN`) |
| **Query DSL** | Filtering, ordering, pagination with type-safe conditions | Full compile-time | Medium (AND/OR predicates, joins, ordering) |
| **SQL Templates** | Complex joins, subqueries, CTEs, window functions, database-specific SQL | Column references checked at compile time, SQL structure at runtime | High (full SQL control) |
-Start with the simplest approach that meets your needs. Use `findBy` or `findAll` for straightforward lookups. Move to the query builder when you need compound filters or pagination. Use SQL templates when you need SQL features the DSL does not cover.
+Start with the simplest approach that meets your needs. Use `findById`, `findBy`, or `findAllBy` for straightforward lookups. Move to the query builder when you need compound filters or pagination. Use SQL templates when you need SQL features the DSL does not cover.
---
@@ -3575,7 +3673,7 @@ val exists: Boolean = orm.existsBy(User_.email, email)
[Java]
-The Java DSL uses the same `EntityRepository` interface as Kotlin. Obtain a repository with `orm.entity(Class)` and use its fluent query builder. Return types use `Optional` for single results and `List` for collections.
+The Java repository exposes the same `EntityRepository` interface as Kotlin. Obtain it with `orm.entity(Class)`. Field-based finders (`findBy`, `findAllBy`, `getBy`) cover single-column lookups; the fluent query builder handles anything more complex. Return types use `Optional` for single results and `List` for collections.
```java
var users = orm.entity(User.class);
@@ -3583,15 +3681,14 @@ var users = orm.entity(User.class);
// Find by ID
Optional user = users.findById(userId);
-// Find all matching
-List usersInCity = users.select()
- .where(User_.city, EQUALS, city)
- .getResultList();
+// Find one by a field value
+Optional byEmail = users.findBy(User_.email, email);
-// Find first matching
-Optional user = users.select()
- .where(User_.email, EQUALS, email)
- .getOptionalResult();
+// Find all matching a field value
+List usersInCity = users.findAllBy(User_.city, city);
+
+// Find all whose field matches any of several values (WHERE ... IN)
+List selected = users.findAllBy(User_.city, cities);
// Count
long count = users.count();
@@ -3633,20 +3730,17 @@ var users = orm.entity(User.class);
// Find by ID
Optional user = users.findById(userId);
-// Find with predicate
-Optional user = users.select()
- .where(User_.email, EQUALS, email)
- .getOptionalResult();
+// Find one by a field value
+Optional byEmail = users.findBy(User_.email, email);
-// Find all matching
-List usersInCity = users.select()
- .where(User_.city, EQUALS, city)
- .getResultList();
+// Require exactly one (throws if none, or if more than one)
+User owner = users.getBy(User_.email, email);
-// Count
-long count = users.count();
+// Find all matching a field value
+List usersInCity = users.findAllBy(User_.city, city);
-// Exists
+// Count and exists
+long count = users.count();
boolean exists = users.existsById(userId);
```
---
@@ -4018,6 +4112,70 @@ List cities = orm.entity(User.class)
```
---
+## Grouped Results
+
+Group results by a related record, typically the parent entity of a foreign key. The metamodel path names the
+group key; the result is a map from parent to its children:
+
+[Kotlin]
+
+```kotlin
+// Load cities with their users in one query
+val usersByCity: Map> = orm.entity()
+ .select()
+ .where(User_.active eq true)
+ .orderBy(User_.city)
+ .resultGroupedBy(User_.city)
+```
+
+[Java]
+
+```java
+// Load cities with their users in one query
+Map> usersByCity = orm.entity(User.class)
+ .select()
+ .where(User_.active, EQUALS, true)
+ .orderBy(User_.city)
+ .getResultGroupedBy(User_.city);
+```
+The SQL is not affected by the grouping: the same select is executed and the results are grouped during
+hydration, so the whole graph loads in a single query. Hydration does not pay for the duplication in the join
+result: repeated group records are materialized once and grouped by instance identity, not by comparing record
+fields. The returned map and its lists are unmodifiable and insertion-ordered; use `orderBy()` to control the
+order of groups and of results within each group. Because duplicate entities within a result set share the same
+instance, each result's reference to its group key is the map key itself.
+
+The where clause keeps its normal meaning: it filters the results, and a group appears only when at least one of
+its results matches. The path must resolve to a non-null record for every result; narrow queries over nullable
+foreign keys with a `where()` clause first. See [Relationships](relationships.md#one-to-many) for the
+one-to-many loading pattern.
+
+The ref-based variant `resultGroupedByRef` (Java: `getResultGroupedByRef`) returns `Map[, List]>`
+instead. Refs are compared by primary key, keeping map lookups constant-cost regardless of the size of the group
+record. For eagerly fetched entity paths the keys are loaded refs: `getOrNull()` returns the record the query
+already materialized, combining primary-key lookups with direct access to the data. The path may also reference
+a `Ref` field, in which case the group is taken directly from the foreign key without fetching the referenced
+record; such refs remain unloaded, and `findAllByRef(map.keys)` fetches them in a single query when needed:
+
+[Kotlin]
+
+```kotlin
+// Group visits by pet without fetching the pets
+val visitsByPet: Map[, List]> = orm.entity()
+ .select()
+ .resultGroupedByRef(Visit_.pet)
+```
+
+[Java]
+
+```java
+// Group visits by pet without fetching the pets
+Map[, List]> visitsByPet = orm.entity(Visit.class)
+ .select()
+ .getResultGroupedByRef(Visit_.pet);
+```
+---
+
## Streaming
[Kotlin]
@@ -5106,8 +5264,8 @@ data class SomeEntity(
record UserEmailUk(int userId, String email) {}
record SomeEntity(@PK Integer id,
- @Nonnull @FK User user,
- @Nonnull String email,
+ @FK User user,
+ String email,
@UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey
) implements Entity {}
```
@@ -5213,7 +5371,7 @@ In standard SQL, `NULL != NULL`. This means a `UNIQUE` constraint typically allo
Because of this, Storm validates nullable unique keys at two levels:
-1. **Compile-time warning.** The metamodel processor emits a warning when a `@UK` field is nullable (a nullable type in Kotlin, or a reference type without `@Nonnull` in Java) and the default `nullsDistinct = true` applies.
+1. **Compile-time warning.** The metamodel processor emits a warning when a `@UK` field is nullable (a nullable type in Kotlin, a `@Nullable` reference type in Java, or any reference type in a `@NullUnmarked` scope) and the default `nullsDistinct = true` applies.
2. **Runtime check.** The `scroll` method throws a `PersistenceException` if the key's metamodel indicates that nulls are distinct for a nullable field, preventing silent data loss.
Database behavior varies. Some databases offer stricter NULL handling for unique constraints:
@@ -5226,10 +5384,10 @@ The `@UK` annotation provides a `nullsDistinct` attribute to control this behavi
| Field | `nullsDistinct` | Effect |
|-------|-----------------|--------|
-| `@UK @Nonnull String email` | (irrelevant) | Safe. No warning, no runtime check. |
+| `@UK String email` | (irrelevant) | Safe. Non-null by default. No warning, no runtime check. |
| `@UK int count` | (irrelevant) | Safe. Primitive is never null. |
-| `@UK String email` | `true` (default) | Compile-time warning. `scroll` throws `PersistenceException`. |
-| `@UK(nullsDistinct = false) String email` | `false` | No warning. `scroll` works (user asserts DB prevents duplicate NULLs). |
+| `@UK @Nullable String email` | `true` (default) | Compile-time warning. `scroll` throws `PersistenceException`. |
+| `@UK(nullsDistinct = false) @Nullable String email` | `false` | No warning. `scroll` works (user asserts DB prevents duplicate NULLs). |
When `nullsDistinct` is set to `false`, you are telling Storm that your database constraint prevents duplicate `NULL` values in the column. Storm trusts this assertion and skips both the compile-time warning and the runtime check. Use this only when your database actually enforces this guarantee (for example, with a `NULLS NOT DISTINCT` unique index in PostgreSQL 15+, or on SQL Server where unique indexes allow at most one `NULL` by default).
@@ -5258,13 +5416,13 @@ data class User(
```java
// Safe (non-nullable)
record User(@PK Integer id,
- @UK @Nonnull String email, // Non-nullable, safe for scrolling
+ @UK String email, // Non-null by default, safe for scrolling
String name
) implements Entity {}
// Opt-in for nullable keys
record User(@PK Integer id,
- @UK(nullsDistinct = false) String email, // DB prevents duplicate NULLs
+ @UK(nullsDistinct = false) @Nullable String email, // DB prevents duplicate NULLs
String name
) implements Entity {}
```
@@ -5747,13 +5905,13 @@ Storm for Kotlin provides a fully programmatic transaction solution (following t
While Storm's `transaction { }` blocks look similar to Exposed's, Storm goes further by supporting all seven standard propagation modes (`REQUIRED`, `REQUIRES_NEW`, `NESTED`, `MANDATORY`, `SUPPORTS`, `NOT_SUPPORTED`, `NEVER`). Exposed's native transaction API only supports basic nesting (shared transaction) and savepoint-based nesting (`useNestedTransactions = true`), without the ability to suspend an outer transaction, enforce transactional context, or run non-transactionally. See [Storm vs Exposed](comparison.md#storm-vs-exposed) for a detailed comparison.
-The API is designed around Kotlin's type system and coroutine model. Import the transaction functions and enums from `st.orm.template`:
+The API is designed around Kotlin's type system and coroutine model. Import the transaction functions from `st.orm.template` and the option enums from `st.orm` (shared with the Java API):
```kotlin
import st.orm.template.transaction
import st.orm.template.transactionBlocking
-import st.orm.template.TransactionPropagation.*
-import st.orm.template.TransactionIsolation.*
+import st.orm.TransactionPropagation.*
+import st.orm.TransactionIsolation.*
```
### Suspend Transactions
@@ -6702,43 +6860,52 @@ withTransactionOptionsBlocking(isolation = SERIALIZABLE) {
}
```
+### How Transactions Bind to Templates
+
+Since 1.13, a `transaction` or `transactionBlocking` block binds to the first `ORMTemplate` that executes inside it. Opening the block only records the requested options (propagation, isolation, timeout, read-only); the actual transaction is opened by that first template's transaction provider. This means the block automatically uses whatever transaction system the template is configured with, whether that is Storm's own JDBC transactions or a platform bridge such as Spring's transaction management. A block that never touches a template completes as a no-op.
+
+Templates that should share a transaction must use the same transaction provider instance. This is automatic for repositories of one application (the Spring Boot starter and the Ktor plugin configure one provider per application context or plugin installation). Mixing templates with *different* transaction providers inside one block fails fast with a descriptive error, since a single commit cannot span two transaction systems.
+
### Spring-Managed Transactions
While Storm's programmatic transaction API works standalone, many applications use Spring's transaction management for its declarative `@Transactional` support and integration with other Spring components. Storm integrates seamlessly with Spring's transaction management.
-When `@EnableTransactionIntegration` is configured, Storm's programmatic `transaction` blocks automatically detect and participate in Spring-managed transactions. This gives you the best of both worlds: Spring's declarative transaction boundaries with Storm's coroutine-friendly transaction blocks.
+When a template is wired to Spring's transaction management, Storm's programmatic `transactionBlocking` blocks run through Spring's `PlatformTransactionManager` and participate in Spring-managed transactions. This gives you the best of both worlds: Spring's declarative transaction boundaries with Storm's programmatic transaction blocks. The suspending `transaction` variant is not supported with Spring-managed transactions; use `transactionBlocking` there.
#### Configuration
-Enable Spring integration in your configuration class:
+The Spring Boot starter wires this automatically when a `PlatformTransactionManager` is present. Without the starter, compose the template with `springOrmTemplate`:
```kotlin
-@EnableTransactionIntegration
@Configuration
-class ORMConfiguration(private val dataSource: DataSource) {
+@EnableTransactionManagement
+class ORMConfiguration {
@Bean
- fun ormTemplate() = ORMTemplate.of(dataSource)
+ fun ormTemplate(
+ dataSource: DataSource,
+ transactionManagers: ObjectProvider,
+ ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() }
}
```
#### Combining Declarative and Programmatic Transactions
-You can use Spring's `@Transactional` annotation alongside Storm's programmatic `transaction` blocks. Storm will join the existing Spring transaction:
+You can use Spring's `@Transactional` annotation alongside Storm's programmatic `transactionBlocking` blocks. Storm will join the existing Spring transaction:
```kotlin
@Service
class UserService(private val orm: ORMTemplate) {
@Transactional
- suspend fun createUserWithOrders(user: User, orders: List) {
+ fun createUserWithOrders(user: User, orders: List) {
// Spring starts the transaction
- transaction {
+ transactionBlocking {
// Storm joins the Spring transaction (REQUIRED propagation by default)
orm insert user
}
- transaction {
+ transactionBlocking {
// Still in the same Spring transaction
orders.forEach { orm insert it }
}
@@ -6828,9 +6995,94 @@ class UserService(private val orm: ORMTemplate) {
[Java]
-Storm for Java follows the principle of integration over invention. Rather than providing its own transaction API, Storm works with your existing transaction infrastructure. Whether you use Spring's `@Transactional` annotation, programmatic `TransactionTemplate`, or direct JDBC connection management, Storm participates correctly in the active transaction.
+Storm for Java provides a fully programmatic transaction API with the same semantics as the Kotlin `transaction { }` blocks: all seven propagation modes, isolation levels, timeouts, read-only transactions, rollback-only marks, and completion callbacks. The blocking API is virtual-thread friendly: the block parks on I/O rather than pinning carrier threads.
-This approach has several benefits: no new APIs to learn, full compatibility with existing code, and consistent behavior across your application. Storm simply uses the JDBC connection associated with the current transaction.
+Storm also integrates with your existing transaction infrastructure: inside Spring applications, `@Transactional` and Spring's own `TransactionTemplate` remain first-class citizens, and Storm participates correctly in the active transaction.
+
+### Programmatic Transactions
+
+Import the static entry points and the option enums:
+
+```java
+import static st.orm.template.Transactions.transaction;
+import static st.orm.template.Transactions.withTransactionOptions;
+import static st.orm.template.Transactions.setGlobalTransactionOptions;
+import st.orm.TransactionOptions;
+import st.orm.TransactionPropagation;
+import st.orm.TransactionIsolation;
+```
+
+The transaction binds to the first ORM template that executes inside the block: opening the block only records the requested options, and the template's transaction provider opens the actual transaction on first use. The block commits when it completes normally and rolls back when it throws; checked exceptions propagate to the caller unchanged and trigger rollback.
+
+```java
+// Commit on success; the block's value is returned.
+User created = transaction(tx -> users.insertAndFetch(user));
+
+// Roll back on exception: the original exception propagates.
+transaction(tx -> {
+ orders.insert(order);
+ inventory.update(stock);
+ return null;
+});
+
+// Checked exceptions need no wrapping: the call site declares what the block throws.
+void importFile(Path path) throws IOException {
+ transaction(tx -> {
+ var data = Files.readString(path); // IOException propagates and rolls back.
+ return imports.insertAndFetch(parse(data));
+ });
+}
+```
+
+### Propagation, Isolation, Timeout, Read-Only
+
+The common case takes the propagation directly; full control goes through `TransactionOptions`, an immutable record with withers. Options left unset are inherited from the surrounding defaults.
+
+```java
+// Independent transaction: commits even if the surrounding transaction rolls back.
+transaction(TransactionPropagation.REQUIRES_NEW, tx -> audit.insertAndFetch(entry));
+
+// Full control.
+transaction(TransactionOptions.defaults()
+ .withIsolation(TransactionIsolation.SERIALIZABLE)
+ .withTimeoutSeconds(30)
+ .withReadOnly(true), tx -> reports.generate());
+```
+
+The propagation semantics are identical to the Kotlin API; see the propagation behavior matrix in the Kotlin tab. `MANDATORY` without an active transaction and `NEVER` inside one fail with a `PersistenceException`; an expired timeout raises `TransactionTimedOutException`; a joined inner scope that marks the transaction rollback-only makes the outer commit raise `UnexpectedRollbackException`.
+
+### Rollback Control and Callbacks
+
+The block receives a `Transaction` handle:
+
+```java
+transaction(tx -> {
+ orders.insert(order);
+ if (!validator.accepts(order)) {
+ tx.setRollbackOnly(); // Complete normally, then roll back.
+ }
+ tx.onCommit(() -> notifications.orderPlaced(order));
+ tx.onRollback(() -> log.warn("Order {} rolled back.", order.id()));
+ return order;
+});
+```
+
+Callbacks registered in a scope that joins an outer transaction are deferred to the outermost physical transaction's completion; `REQUIRES_NEW` scopes fire their own callbacks independently. Callbacks run in registration order after the transaction has fully completed; if one throws, the remaining callbacks still execute and the first exception is surfaced with the others suppressed.
+
+### Global and Scoped Defaults
+
+```java
+// Application-wide defaults, typically set once at startup.
+setGlobalTransactionOptions(TransactionOptions.defaults().withTimeoutSeconds(60));
+
+// Thread-scoped defaults for a code region; restored afterwards.
+withTransactionOptions(TransactionOptions.defaults().withReadOnly(true), () -> {
+ var summary = transaction(tx -> reports.summarize());
+ return summary;
+});
+```
+
+Explicit options on a `transaction(...)` call always win over scoped defaults, which win over the global defaults.
### Spring-Managed Transactions
@@ -6838,7 +7090,7 @@ Spring's transaction management is the most common approach for Java enterprise
#### Configuration
-Configure Storm with Spring's transaction management:
+Configure Storm with Spring's transaction management. The Spring Boot starter does this automatically when a `PlatformTransactionManager` is present; without the starter, compose the template with `SpringOrmTemplate.of`:
```java
@Configuration
@@ -6846,17 +7098,20 @@ Configure Storm with Spring's transaction management:
public class ORMConfiguration {
@Bean
- public ORMTemplate ormTemplate(DataSource dataSource) {
- return ORMTemplate.of(dataSource);
+ public PlatformTransactionManager transactionManager(DataSource dataSource) {
+ return new DataSourceTransactionManager(dataSource);
}
@Bean
- public PlatformTransactionManager transactionManager(DataSource dataSource) {
- return new DataSourceTransactionManager(dataSource);
+ public ORMTemplate ormTemplate(DataSource dataSource,
+ ObjectProvider transactionManagers) {
+ return SpringOrmTemplate.of(dataSource, () -> transactionManagers.orderedStream().toList());
}
}
```
+With this composition, Spring's `@Transactional` and Storm's programmatic `transaction(...)` blocks share the same transaction system: a Storm block inside a `@Transactional` method joins the Spring-managed transaction, and a standalone Storm block runs through Spring's transaction manager.
+
#### Declarative Transactions with @Transactional
Use Spring's `@Transactional` annotation on service methods. Storm automatically participates in the active transaction:
@@ -7150,15 +7405,18 @@ class ORMConfiguration(private val dataSource: DataSource) {
### Transaction Integration
-By default, Storm manages its own transactions independently of Spring's transaction context. The `@EnableTransactionIntegration` annotation bridges the two systems so that Storm's programmatic `transaction` and `transactionBlocking` blocks participate in Spring-managed transactions. Without this annotation, a transaction block inside a `@Transactional` method would open a separate database connection and transaction.
+A template created with `dataSource.orm` manages its own transactions, independently of Spring's transaction context: a transaction block inside a `@Transactional` method would open a separate database connection and transaction. To wire a template to Spring's transaction management instead, compose it with `springOrmTemplate` (from `st.orm.spring.kotlin`), which hands the Spring-aware connection and transaction providers to that specific template. Since 1.13 the integration is per template rather than JVM-global, so multiple application contexts, and plain templates next to Spring-managed ones, coexist without interference.
```kotlin
-@EnableTransactionIntegration
@Configuration
-class ORMConfiguration(private val dataSource: DataSource) {
+@EnableTransactionManagement
+class ORMConfiguration {
@Bean
- fun ormTemplate(): ORMTemplate = dataSource.orm
+ fun ormTemplate(
+ dataSource: DataSource,
+ transactionManagers: ObjectProvider,
+ ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() }
}
```
@@ -7178,14 +7436,24 @@ fun processUsers() {
### Repository Injection
-Storm repositories are interfaces with default method implementations. Spring cannot discover them automatically because they are not annotated with `@Component` or `@Repository`. The `RepositoryBeanFactoryPostProcessor` scans specified packages for interfaces that extend `EntityRepository` or `ProjectionRepository` and registers them as Spring beans. This makes them available for constructor injection like any other Spring-managed dependency.
+Storm repositories are interfaces with default method implementations. Spring cannot discover them automatically because they are not annotated with `@Component` or `@Repository`. Enable scanning with `@EnableStormRepositories`, mirroring annotations like `@EnableJpaRepositories`: the given packages are scanned for interfaces that extend `EntityRepository` or `ProjectionRepository`, and each is registered as a Spring bean, available for constructor injection like any other Spring-managed dependency.
```kotlin
@Configuration
-class AcmeRepositoryBeanFactoryPostProcessor : RepositoryBeanFactoryPostProcessor() {
+@EnableStormRepositories(basePackages = ["com.acme.repository"])
+class AcmeConfiguration
+```
+
+Without explicit packages, the package of the annotated class is scanned. For full control, or for multiple repository sets bound to different templates, define `RepositoryBeanFactoryPostProcessor` beans (from `st.orm.spring.kotlin`) instead:
+
+```kotlin
+@Configuration
+class AcmeRepositoryConfiguration {
- override val repositoryBasePackages: Array
- get() = arrayOf("com.acme.repository")
+ @Bean
+ fun acmeRepositories() = RepositoryBeanFactoryPostProcessor(
+ basePackages = arrayOf("com.acme.repository"),
+ )
}
```
@@ -7233,36 +7501,44 @@ class UserService(
[Java]
-The configuration mirrors the Kotlin setup. Define a single `ORMTemplate` bean that wraps the Spring-managed `DataSource`.
+The configuration mirrors the Kotlin setup. Define a single `ORMTemplate` bean; `SpringOrmTemplate.of` wires it to Spring's transaction management, so Storm's programmatic `transaction` blocks run through Spring's transaction managers and cooperate with `@Transactional`.
```java
@Configuration
+@EnableTransactionManagement
public class ORMConfiguration {
- private final DataSource dataSource;
-
- public ORMConfiguration(DataSource dataSource) {
- this.dataSource = dataSource;
- }
-
@Bean
- public ORMTemplate ormTemplate() {
- return ORMTemplate.of(dataSource);
+ public ORMTemplate ormTemplate(DataSource dataSource,
+ ObjectProvider transactionManagers) {
+ return SpringOrmTemplate.of(dataSource, () -> transactionManagers.orderedStream().toList());
}
}
```
+A template created with `ORMTemplate.of(dataSource)` works too, but manages its own transactions independently of Spring's transaction context.
+
### Repository Injection
-Register a `RepositoryBeanFactoryPostProcessor` that scans your repository packages. This works identically to the Kotlin version: Storm discovers interfaces extending `EntityRepository` or `ProjectionRepository` and registers them as Spring beans.
+Enable repository scanning with `@EnableStormRepositories`. This works identically to the Kotlin version: Storm discovers interfaces extending `EntityRepository` or `ProjectionRepository` and registers them as Spring beans.
```java
@Configuration
-public class AcmeRepositoryBeanFactoryPostProcessor extends RepositoryBeanFactoryPostProcessor {
+@EnableStormRepositories(basePackages = "com.acme.repository")
+public class AcmeConfiguration {
+}
+```
- @Override
- public String[] getRepositoryBasePackages() {
- return new String[] { "com.acme.repository" };
+For full control, or for multiple repository sets bound to different templates, define `RepositoryBeanFactoryPostProcessor` beans instead:
+
+```java
+@Configuration
+public class AcmeRepositoryConfiguration {
+
+ @Bean
+ public RepositoryBeanFactoryPostProcessor acmeRepositories() {
+ return new RepositoryBeanFactoryPostProcessor(
+ new String[] { "com.acme.repository" }, null, "");
}
}
```
@@ -7473,10 +7749,12 @@ The Spring Boot Starter modules provide zero-configuration setup for Storm. Add
The starter auto-configures:
-1. **`ORMTemplate` bean** created from the auto-configured `DataSource`. If you define your own `ORMTemplate` bean, the auto-configured one backs off.
+1. **`ORMTemplate` bean** created from the auto-configured `DataSource`, composed with the Spring-aware integration strategies below. If you define your own `ORMTemplate` bean, the auto-configured one backs off.
2. **Repository scanning** via `AutoConfiguredRepositoryBeanFactoryPostProcessor`, which discovers repository interfaces in the `@SpringBootApplication` base package (and its sub-packages). If you define your own `RepositoryBeanFactoryPostProcessor` bean, the auto-configured one backs off.
-3. **Transaction integration** (Kotlin only) by automatically activating `SpringTransactionConfiguration`, removing the need for `@EnableTransactionIntegration`.
-4. **Configuration properties** bound from `storm.*` in `application.yml`/`application.properties`, passed to the `ORMTemplate` via `StormConfig`.
+3. **Transaction integration** through Spring-aware `ConnectionProvider` and `TransactionTemplateProvider` beans, contributed when a `PlatformTransactionManager` is present and handed to the template it creates. Nothing is registered globally: each application context gets its own, correctly matched transaction integration. Define your own `ConnectionProvider` or `TransactionTemplateProvider` bean to override, and optionally contribute `ExceptionMapper` or `QueryObserver` beans, which are applied to the template the same way.
+4. **Exception translation** to Spring's `DataAccessException` hierarchy through an auto-configured `ExceptionMapper` bean. Disable with `storm.exception-translation.enabled=false`, or define your own `ExceptionMapper` bean to translate differently.
+5. **Query observations** through Micrometer when an `ObservationRegistry` bean is present: every query reports as a `storm.query` observation, yielding actuator metrics and tracing spans from a single instrumentation.
+6. **Configuration properties** bound from `storm.*` in `application.yml`/`application.properties`, passed to the `ORMTemplate` via `StormConfig`.
### Minimal Spring Boot Setup (with Starter)
@@ -7517,11 +7795,17 @@ storm:
validation:
skip: false
warnings-only: false
- schema-mode: none
+ schema-mode: fail
strict: false
+ exception-translation:
+ enabled: true
+ observations:
+ semantic-conventions: storm
+ tracing:
+ sql-comments: false
```
-The `schema-mode` property controls startup schema validation: `none` (default) skips validation, `warn` logs mismatches without blocking startup, and `fail` blocks startup if any entity definitions do not match the database schema. The `strict` property controls whether warnings (type narrowing, nullability mismatches) are treated as errors. See the [Configuration](configuration.md#schema-validation) guide for details.
+The `schema-mode` property controls startup schema validation: `fail` (default) blocks startup if any entity definitions do not match the database schema, `warn` logs mismatches without blocking startup, and `none` skips validation. The `strict` property controls whether warnings (type narrowing, nullability mismatches) are treated as errors. See the [Configuration](configuration.md#schema-validation) guide for details.
See the [Configuration](configuration.md) guide for a description of each property and the full precedence rules.
@@ -7545,11 +7829,8 @@ class StormConfig(private val dataSource: DataSource) {
```kotlin
@Configuration
-class MyRepositoryPostProcessor : RepositoryBeanFactoryPostProcessor() {
-
- override val repositoryBasePackages: Array
- get() = arrayOf("com.myapp.repository", "com.myapp.other")
-}
+@EnableStormRepositories(basePackages = ["com.myapp.repository", "com.myapp.other"])
+class RepositoryConfiguration
```
### Minimal Spring Boot Setup (without Starter)
@@ -7562,19 +7843,18 @@ If you use the integration module directly (without the starter), you need to co
class Application
@Configuration
-@EnableTransactionIntegration
-class StormConfig(private val dataSource: DataSource) {
+class StormConfig {
@Bean
- fun ormTemplate() = dataSource.orm
+ fun ormTemplate(
+ dataSource: DataSource,
+ transactionManagers: ObjectProvider,
+ ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() }
}
@Configuration
-class MyRepositoryBeanFactoryPostProcessor : RepositoryBeanFactoryPostProcessor() {
-
- override val repositoryBasePackages: Array
- get() = arrayOf("com.myapp.repository")
-}
+@EnableStormRepositories(basePackages = ["com.myapp.repository"])
+class RepositoryConfiguration
```
This gives you:
@@ -7582,6 +7862,147 @@ This gives you:
- Transaction integration between Spring and Storm
- Repository auto-discovery and injection
+## Exception Translation
+
+Spring code is written against the `DataAccessException` hierarchy: retry setups key on `TransientDataAccessException` for deadlocks and lock timeouts, rollback rules and catch blocks reference the Spring types, and Spring's other data access integrations all translate consistently. With the starter, Storm participates in that convention out of the box: SQL failures raised by Storm repositories and templates surface as Spring exceptions, translated from vendor error codes for the application's database product, with `SQLException` subclass and SQL state translation as fallback.
+
+```kotlin
+try {
+ userRepository.insert(user)
+} catch (exception: DuplicateKeyException) {
+ // Same exception type a JdbcClient or JPA repository would raise.
+}
+```
+
+Failures without a `SQLException` cause keep Storm's own semantics: `PersistenceException` and its subtypes, such as optimistic locking and result cardinality failures, pass through unchanged. The boundary is the database: what the database reports is translated, what Storm's own logic detects stays a Storm exception in every composition. Retry setups that should also retry optimistic lock failures list both types:
+
+```kotlin
+@Retryable(retryFor = [TransientDataAccessException::class, OptimisticLockException::class])
+fun updateStudy(study: Study) = transactionBlocking { studyRepository.update(study) }
+```
+
+Translation applies to the templates composed with it: the starter's auto-configured template, and templates created with `SpringOrmTemplate.of` (Java) or `springOrmTemplate` (Kotlin). Disable it with `storm.exception-translation.enabled=false`, define your own `ExceptionMapper` bean, or compose the template yourself with the builder and leave the mapper out.
+
+## Observability
+
+The starters ship with Storm's Micrometer binding. When an `ObservationRegistry` bean is present (Spring Boot Actuator provides one out of the box), every query executed by the auto-configured template is reported as a Micrometer Observation named `storm.query`. One instrumentation yields both actuator metrics (`storm.query` timers, tagged and queryable per operation and entity) and tracing spans when a tracing backend is configured.
+
+A generic DataSource proxy can only time statements. Storm knows the entity and operation behind every statement it generates, so the observations carry meaningful tags:
+
+- **Low-cardinality key values** (become metric tags): the SQL operation (`SELECT`, `INSERT`, ...), the execution kind (query, update, batch), and the entity or projection type.
+- **High-cardinality key values** (visible to trace handlers only): the SQL statement with parameter placeholders. Parameter values are never reported.
+
+Transactions are observed alongside the queries: every physical transaction — an outermost `transaction` block or a `REQUIRES_NEW` block, in any language stack and through the Spring bridge alike — reports as a `storm.transaction` observation with its duration, `storm.tx.outcome` (`committed` or `rolled_back`), `storm.tx.propagation`, and `storm.tx.read_only`. Joined blocks run inside an existing transaction and are deliberately not double-counted. Long-running transactions and rollback rates become queryable per application, and per domain with the multi-template tagging.
+
+Observability backends key their database tooling — latency panels, service-map database nodes, query views — on the OpenTelemetry database client semantic conventions. Set `storm.observations.semantic-conventions: otel` and every observation additionally carries the standard attributes (`db.system.name` detected from the DataSource, `db.operation.name`, and `db.query.text` on spans), so Storm queries surface in the database UX of any OTLP-capable backend, from Elastic to Grafana to Datadog, while the `storm.*` key values remain for custom dashboards:
+
+```yaml
+storm:
+ observations:
+ semantic-conventions: otel
+```
+
+Customization follows the usual back-off rules: contribute an `ObservationConvention` bean to override the naming and key values (it wins over the property), define your own `QueryObserver` bean to replace the binding entirely, or disable the observation at the registry level with `management.observations.enable.storm.query=false`.
+
+With tracing in place, `storm.tracing.sql-comments` additionally appends the current trace context to statements as a sqlcommenter-style comment (`/*traceparent='00-…'*/`), so database-side diagnostics — MariaDB's slow query log, `pg_stat_activity` — correlate directly back to the trace that issued the statement. `true` comments every statement inside a span; `sampled` comments only statements of sampled traces, which is the recommended mode when the sampling probability is below 1.0. This is deliberately opt-in: a per-execution comment changes the statement text on every call, which defeats driver-side and server-side prepared statement caching.
+
+One composition warning for applications that wire the integration beans themselves: define beans like the `ExceptionMapper`, `QueryObserver`, or `SqlCommenter` unconditionally in your `@Configuration` classes. A `@ConditionalOnBean` condition in a user configuration evaluates before auto-configurations contribute their beans (such as the `Tracer`), so it fails silently and the integration stays dormant while looking enabled.
+
+## Testing with @DataStormTest
+
+`@DataStormTest` (from `storm-spring-boot-test-autoconfigure`, test scope) is the Storm test slice, the counterpart of `@DataJpaTest`: it starts only the `DataSource`, Storm's auto-configuration (template, repository scanning, transaction integration, schema validation, exception translation), and SQL initialization, plus Flyway or Liquibase when present. It complements [`@StormTest`](testing.md), which tests data logic without a Spring context: use `@StormTest` for fast query-level tests, and the slice when the test should see what production Spring code sees — injected repository beans, translated exceptions, Spring-managed transactions, and your `storm.*` configuration. Regular `@Component`, `@Service`, and `@Controller` beans stay out of the context. Each test method runs in a transaction that is rolled back afterwards, so tests cannot see each other's writes.
+
+The application's `DataSource` is replaced with an embedded in-memory database by default, on Spring Boot 3 and 4 alike (the slice ships a fallback for Boot 4's relocated test-database support); put a `schema.sql` (and optionally `data.sql`) on the test classpath to initialize it, or let Flyway or Liquibase (included in the slice) create the schema as in production:
+
+```kotlin
+@DataStormTest
+class VisitRepositoryTest(
+ @Autowired private val visitRepository: VisitRepository,
+) {
+
+ @Test
+ fun `finds all visits`() {
+ visitRepository.count() shouldBe 14
+ }
+
+ @Test
+ fun `insert rolls back after the test`() {
+ visitRepository.insert(Visit(description = "temporary"))
+ }
+}
+```
+
+To run against a real database instead, disable the replacement with `spring.test.database.replace=none` and hand the slice a Testcontainers-managed database through `@ServiceConnection`:
+
+```kotlin
+@DataStormTest(properties = ["spring.test.database.replace=none"])
+@Testcontainers
+class VisitRepositoryPostgresTest(
+ @Autowired private val visitRepository: VisitRepository,
+) {
+
+ companion object {
+ @Container
+ @ServiceConnection
+ @JvmStatic
+ val postgres = PostgreSQLContainer("postgres:17-alpine")
+ }
+
+ @Test
+ fun `finds all visits`() {
+ visitRepository.count() shouldBe 14
+ }
+}
+```
+
+The slice works with both starters — it pulls in the starter's auto-configuration classes by name, which are identical for the Java and Kotlin stacks — and with both Spring Boot 3 and 4: where Spring Boot moved classes between the releases, the slice is exclusion-based rather than annotation-composed. The annotation supports `properties` for per-test configuration (for example `properties = ["storm.validation.schema-mode=none"]` when the test schema deliberately diverges from the entity model) and `includeFilters`/`excludeFilters` to pull additional components into the slice.
+
+Two composition notes. Test fixtures loaded per test method participate in the rollback transaction, so identity-generated keys drift across methods (sequences do not roll back); load reference fixtures once with `@Sql(executionPhase = ExecutionPhase.BEFORE_TEST_CLASS)` and let each test's own writes roll back. And an application that excludes `StormTransactionAutoConfiguration` (the coroutine-native setup) should re-enable it for the slice with `properties = ["spring.autoconfigure.exclude="]` — without the transaction bridge, repository writes cannot join the rollback transaction.
+
+## Multiple Data Sources
+
+Larger applications sometimes expose the same database through several `DataSource` beans: one connection pool per domain, each with its own pool size, timeout, and isolation settings, so heavy batch work cannot starve interactive traffic. Storm supports this topology with one `ORMTemplate` per `DataSource` and one repository post-processor per domain.
+
+```kotlin
+@Configuration
+class BillingConfiguration {
+
+ @Bean
+ fun billingDataSource(): DataSource = /* dedicated pool for the billing domain */
+
+ @Bean
+ fun billingTransactionManager(@Qualifier("billingDataSource") dataSource: DataSource): PlatformTransactionManager =
+ DataSourceTransactionManager(dataSource)
+
+ @Bean
+ fun billingTemplate(
+ @Qualifier("billingDataSource") dataSource: DataSource,
+ transactionManagers: ObjectProvider,
+ ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() }
+}
+```
+
+Each template passes the full transaction manager list; the Spring bridge selects the manager that matches the template's `DataSource`, so every domain transacts through its own pool.
+
+Repositories bind to a specific template through a per-domain post-processor. The `repositoryPrefix` keeps bean names apart when the same repository interface is registered for several domains:
+
+```kotlin
+@Configuration
+class BillingRepositoryConfiguration {
+
+ @Bean
+ fun billingRepositories() = RepositoryBeanFactoryPostProcessor(
+ basePackages = arrayOf("com.myapp.billing.repository"),
+ ormTemplateBeanName = "billingTemplate",
+ repositoryPrefix = "billing",
+ )
+}
+```
+
+The starter cooperates with this setup: with several `DataSource` beans and no `@Primary`, the auto-configured `ORMTemplate` and schema validation back off (there is no single candidate to bind to); mark one pool `@Primary` and they bind to that one. The auto-configured repository scanning backs off as soon as you define your own post-processors, and the Spring-aware `ConnectionProvider` and `TransactionTemplateProvider` beans remain available for injection into your own template definitions.
+
+Storm's programmatic transaction API needs no per-domain configuration: a `transaction` block binds to the template used inside it, so each domain's blocks run against that domain's pool and transaction manager.
+
## JPA Entity Manager
Storm can create an `ORMTemplate` from a JPA `EntityManager`, which lets you use Storm queries within existing JPA transactions and services. This is particularly useful during incremental [migration from JPA](migration-from-jpa.md), where you can convert one repository or query at a time without changing your transaction management strategy.
@@ -7599,7 +8020,7 @@ public void doWork() {
## Transaction Propagation
-When `@EnableTransactionIntegration` is active, Storm's programmatic transactions participate in Spring's transaction propagation. This means a `transaction` or `transactionBlocking` block checks for an existing Spring-managed transaction before starting a new one. If a transaction already exists, the block joins it. If not, it creates a new independent transaction.
+When a template is wired to Spring's transaction management (via `springOrmTemplate` or the starter), Storm's programmatic transactions participate in Spring's transaction propagation. This means a `transaction` or `transactionBlocking` block checks for an existing Spring-managed transaction before starting a new one. If a transaction already exists, the block joins it. If not, it creates a new independent transaction.
Understanding this behavior is important for controlling atomicity. When multiple operations must commit or roll back as a unit, they need to share the same transaction. When operations should be independent (for example, logging that should persist even if the main operation fails), they need separate transactions.
@@ -7950,6 +8371,8 @@ Writing tests for database code can involve repetitive setup: creating a `DataSo
The module provides two categories of functionality:
1. **JUnit 5 integration** (`@StormTest`) for automatic database setup, script execution, and parameter injection.
+
+Spring Boot applications additionally have the [`@DataStormTest` slice](spring-integration.md#testing-with-datastormtest), which boots the Storm part of the Spring context instead of bypassing it: repositories arrive as Spring beans, exceptions arrive translated, and each test rolls back a Spring-managed transaction. `@StormTest` stays the fastest option for query-level tests; the slice covers the Spring wiring.
2. **Statement capture** (`SqlCapture`) for recording and inspecting SQL statements generated during test execution. This component is framework-agnostic and works independently of JUnit.
---
@@ -10739,6 +11162,9 @@ An unloaded ref serializes as a bare value because there is nothing more to conv
A loaded entity ref wraps the full entity data in an `@entity` object. This tells the deserializer that the enclosed data is a complete entity, from which it can reconstruct a loaded ref with `getOrNull()` returning the entity instance.
+> **Warning:**
+Serialization never triggers a fetch, but a ref that is already loaded (for example because a query joined it) serializes its full entity, including every field of the referenced type. When the response shape matters, such as a public REST API, return a projection that exposes only the intended fields rather than an entity whose refs may carry more than the endpoint should reveal. Serialization reflects exactly what is loaded; it does not filter.
+
A loaded projection ref uses a different wrapper (`@projection`) and includes a separate `@id` field. The explicit ID is necessary because projections are partial views of an entity and may not expose an `id()` accessor. Without the separate `@id` field, the deserializer would have no reliable way to recover the primary key.
Both Jackson and kotlinx.serialization produce identical JSON for the same ref state, so output from one library can be consumed by the other.
@@ -11185,7 +11611,7 @@ data class LegacyUser(
```java
@DbIgnore
record LegacyUser(@PK Integer id,
- @Nonnull String name
+ String name
) implements Entity {}
```
**Suppress validation for a specific field:**
@@ -11205,9 +11631,9 @@ data class User(
```java
record User(@PK Integer id,
- @Nonnull String name,
+ String name,
@DbIgnore("DB uses FLOAT, but column only stores whole numbers")
- @Nonnull Integer age
+ Integer age
) implements Entity {}
```
The optional `value` parameter documents why the mismatch is acceptable. When `@DbIgnore` is placed on an inline component field, validation is suppressed for all columns within that component.
@@ -11231,7 +11657,7 @@ data class Report(
```java
@DbTable(schema = "reporting")
record Report(@PK Integer id,
- @Nonnull String name
+ String name
) implements Entity {}
```
### Spring Boot Configuration
@@ -11242,7 +11668,7 @@ When using the Spring Boot Starter, both record and schema validation can be con
storm:
validation:
record-mode: fail # or "warn" or "none" (default: fail)
- schema-mode: none # or "warn" or "fail" (default: none)
+ schema-mode: fail # or "warn" or "none" (default: fail)
strict: false # treat schema warnings as errors (default: false)
```
@@ -11250,16 +11676,16 @@ The `schema-mode` values:
| Value | Behavior |
|-------|----------|
-| `none` | Schema validation is skipped (default). |
+| `fail` | Mismatches cause startup to fail with a `PersistenceException` (default). |
| `warn` | Mismatches are logged at WARN level; startup continues. |
-| `fail` | Mismatches cause startup to fail with a `PersistenceException`. |
+| `none` | Schema validation is skipped. |
### Configuration Properties
| Property | Default | Description |
|----------|---------|-------------|
| `storm.validation.record_mode` | `fail` | Record validation mode: `fail`, `warn`, or `none` |
-| `storm.validation.schema_mode` | `none` | Schema validation mode: `none`, `warn`, or `fail` (Spring Boot only) |
+| `storm.validation.schema_mode` | `fail` | Schema validation mode: `none`, `warn`, or `fail` (Spring Boot and Ktor) |
| `storm.validation.strict` | `false` | When `true`, schema validation warnings are treated as errors |
@@ -11724,6 +12150,166 @@ orm.entity(User.class).upsert(new User("alice@example.com", "Alice", birthDate,
5. **Be mindful of multiple unique constraints** - especially on MySQL/MariaDB, where any unique constraint can trigger the update branch
+========================================
+## Source: write-sets.md
+========================================
+
+
+# Write Sets
+
+Inserting an object graph that spans several tables normally requires careful choreography: insert the parents first, collect their generated keys, rebuild the children against those keys, and repeat for every level. Write sets lift that burden.
+
+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.
+
+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):
+
+[Kotlin]
+
+```kotlin
+orm.writeSet().insert(entities)
+
+// or scoped; each verb executes immediately, the block only groups the calls
+transaction {
+ orm.writeSet {
+ insert(newEntities)
+ update(changedEntities)
+ remove(staleEntities)
+ }
+}
+```
+
+[Java]
+
+```java
+orm.writeSet().insert(entities);
+```
+---
+
+## Inserting a Graph
+
+Consider the classic schema where a `Pet` references an `Owner` and a `Visit` references a `Pet`. With a write set, the whole graph is built in memory first, linking children to their parents by holding the parent instance, and inserted in one call:
+
+[Kotlin]
+
+```kotlin
+val owner = Owner(firstName = "Alice", lastName = "Bond", address = address) // unsaved
+val wolfie = Pet(name = "Wolfie", birthDate = birthDate, type = dog, owner = owner)
+val rex = Pet(name = "Rex", birthDate = birthDate, type = dog, owner = owner) // same owner instance
+val visit = Visit(visitDate = today, description = "Check-up", pet = wolfie)
+
+orm.writeSet().insert(listOf(wolfie, rex, visit))
+```
+
+[Java]
+
+```java
+var owner = new Owner("Alice", "Bond", address); // unsaved
+var wolfie = new Pet("Wolfie", birthDate, dog, owner);
+var rex = new Pet("Rex", birthDate, dog, owner); // same owner instance
+var visit = new Visit(today, "Check-up", wolfie);
+
+orm.writeSet().insert(List.of(wolfie, rex, visit));
+```
+This particular graph needs three dependency-ordered batch operations, regardless of how many pets or visits the set contains: owners first, then pets, then visits. The owner was never passed explicitly; it becomes a discovered member because the pet values hold it in their foreign key field. That is the insertion closure at work: a record whose foreign key field holds an unsaved entity is a value that describes both rows, and inserting the value inserts both.
+
+The rules, in full:
+
+1. **Insert and upsert write the explicit members plus their insertion closure.** Discovery follows insertable, entity-valued foreign key fields (including fields inside inline components) and entity-wrapped refs, and picks up unsaved entities transitively. Referenced entities that already carry a primary key are never discovered; unless they are explicit members themselves, they only provide foreign key values.
+2. **Update and remove write exactly the explicit members.** Referenced entities are never updated or removed implicitly.
+3. **Storm determines a valid execution order** from the foreign key dependencies: parents before children for insert and upsert, children before parents for remove.
+4. **Generated keys propagate by instance identity.** Children link to a new parent by holding the same instance: one unsaved instance describes one prospective row, and two structurally equal but distinct unsaved instances describe two separate rows.
+5. **Execution is grouped into one batch operation per entity type per dependency level** (large batches are split by the configured batch size). The batch count follows the dependency shape of the data: a self-referencing type whose rows span several dependency levels needs one batch per level.
+
+An entity counts as *unsaved* when its primary key is the default value and the key is auto-generated. The test is local and deterministic; no session state or database round trip is involved.
+
+A write set executes multiple statements and is not atomic by itself: if a later dependency level fails, earlier levels have already been written. Run write sets inside a transaction when atomicity across the set is required.
+
+> **Warning:**
+
+Key propagation correlates by instance, not by `equals`. A `copy()` of an unsaved parent is a different row. Link children to the exact instance you want them to share.
+## Fetching the Result
+
+`insertAndFetch` returns the passed entities as they exist in the database after the write, in input order, with generated keys, defaults and version columns reflected and referenced parents hydrated. Single-root variants accept one entity and return it typed, so the common "insert this graph, give me the keyed result" is one call:
+
+[Kotlin]
+
+```kotlin
+val inserted = orm.writeSet().insertAndFetch(listOf(wolfie, visit))
+val fetchedPet = inserted[0] as Pet // fetchedPet.owner carries the generated key
+
+val fetchedVisit: Visit = orm.writeSet().insertAndFetch(visit) // single root, typed
+```
+
+[Java]
+
+```java
+var inserted = orm.writeSet().insertAndFetch(List.of(wolfie, visit));
+var fetchedPet = (Pet) inserted.get(0); // fetchedPet.owner() carries the generated key
+
+Visit fetchedVisit = orm.writeSet().insertAndFetch(visit); // single root, typed
+```
+## Refs
+
+Foreign key fields typed as `Ref` participate through entity-wrapped refs, which carry the instance:
+
+[Kotlin]
+
+```kotlin
+val pet = Pet(name = "Shadow", birthDate = birthDate, type = dog, owner = Ref.of(owner))
+orm.writeSet().insert(listOf(pet)) // owner is inserted first, the ref binds the generated key
+```
+
+[Java]
+
+```java
+var pet = new Pet("Shadow", birthDate, dog, Ref.of(owner));
+orm.writeSet().insert(List.of(pet)); // owner is inserted first, the ref binds the generated key
+```
+Id-only refs such as `Ref.of(Owner::class, 42)` are pointers to known rows: they bind as foreign key values and are never written. An id-only ref carrying a default id cannot describe a new entity and fails fast. Note that ref equality is based on type and id, so refs wrapping distinct unsaved instances compare equal until the instances are persisted; do not use unsaved refs as map keys.
+
+## Update, Upsert and Remove
+
+The remaining verbs 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.
+- `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.
+
+[Kotlin]
+
+```kotlin
+orm.writeSet().remove(listOf(owner, pet, visit)) // executed as: visit, pet, owner
+```
+
+[Java]
+
+```java
+orm.writeSet().remove(List.of(owner, pet, visit)); // executed as: visit, pet, owner
+```
+## Fail-Fast Behavior
+
+Write sets keep Storm's fail-fast doctrine. Each of the following raises a descriptive `PersistenceException` before any statement is executed:
+
+- A dependency cycle that cannot be executed by the dependency-ordering strategy. The write set does not break cycles using nullable intermediate values, deferred constraints, or follow-up updates.
+- An id-only ref with a default id in an insert or upsert set (it cannot describe a new entity; wrap the instance instead).
+- An unsaved entity passed to update or remove.
+- An unsaved entity referenced through a non-insertable foreign key component (typically a junction table whose key columns live inside a composite primary key).
+
+References to entities outside the effective write set follow the normal repository rules: keyed references only provide foreign key values and are never written; unsaved references fail wherever their key is required as a foreign key value.
+
+## What Write Sets Do Not Do
+
+Write sets complete Storm's persistence story without introducing a session. Each concern has a dedicated tool:
+
+- **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.
+- **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.
+
+
========================================
## Source: sql-templates.md
========================================
@@ -11800,7 +12386,7 @@ val pets = orm.query { """
```java
@DbTable("pet")
record PetWithOwner(
- @Nonnull String name,
+ String name,
@Nullable LocalDate birthDate,
@FK Owner owner
) implements Data {}
@@ -11877,17 +12463,17 @@ orm.query { """
```java
record Country(@PK Integer id,
- @Nonnull String name,
- @Nonnull String code
+ String name,
+ String code
) implements Entity {}
record City(@PK Integer id,
- @Nonnull String name,
+ String name,
@FK Country country
) implements Entity {}
record User(@PK Integer id,
- @Nonnull String email,
+ String email,
@FK City city
) implements Entity {}
```
@@ -12994,15 +13580,17 @@ If a non-nullable field receives NULL from the database, Storm throws an excepti
[Java]
-Use `@Nonnull` and `@Nullable` annotations:
+Record components are non-null by default, exactly like Kotlin. Mark nullable fields with `@Nullable`:
```java
record User(
int id, // Primitive = non-nullable
- @Nonnull String email, // Non-nullable
+ String email, // Non-nullable (default)
@Nullable String nickname // Nullable
) {}
```
+
+If a non-nullable field receives NULL from the database, Storm throws an exception.
### Nullable Nested Records
When a nested record field is nullable, Storm checks if **all** its columns are NULL:
@@ -13357,8 +13945,8 @@ data class User(
```java
@DynamicUpdate(FIELD)
record User(@PK Integer id,
- @Nonnull String email,
- @Nonnull String name,
+ String email,
+ String name,
@FK City city
) implements Entity {}
```
@@ -14324,7 +14912,7 @@ Storm can be configured through `StormConfig`, system properties, Spring Boot's
| `storm.entity_cache.retention` | `default` | Cache retention mode: `default` or `light` |
| `storm.template_cache.size` | `2048` | Maximum number of compiled templates to cache |
| `storm.validation.record_mode` | `fail` | Record validation mode: `fail`, `warn`, or `none` |
-| `storm.validation.schema_mode` | `none` | Schema validation mode: `none`, `warn`, or `fail` (Spring Boot and Ktor) |
+| `storm.validation.schema_mode` | `fail` | Schema validation mode: `none`, `warn`, or `fail` (Spring Boot and Ktor) |
| `storm.validation.strict` | `false` | Treat schema validation warnings as errors |
| `storm.validation.interpolation_mode` | `warn` | Interpolation safety mode: `warn`, `fail`, or `none` (see [Interpolation Safety](#interpolation-safety)) |
| `st.orm.scrollable.maxSize` | `1000` | Maximum window size allowed in a serialized cursor (system property only) |
@@ -14389,8 +14977,14 @@ storm:
size: 2048
validation:
record-mode: fail
- schema-mode: none
+ schema-mode: fail
strict: false
+ exception-translation:
+ enabled: true
+ observations:
+ semantic-conventions: storm
+ tracing:
+ sql-comments: false
```
The Spring Boot Starter binds these properties and builds a `StormConfig` that is passed to the `ORMTemplate` factory. Values not set in YAML fall back to system properties and then to built-in defaults. See [Spring Integration](spring-integration.md#configuration-via-applicationyml) for details.
@@ -14413,7 +15007,7 @@ storm {
}
validation {
recordMode = "fail"
- schemaMode = "none"
+ schemaMode = "fail"
strict = false
}
}
@@ -14899,7 +15493,7 @@ Storm's Kotlin API uses the Storm compiler plugin to automatically wrap string i
See [String Templates](string-templates.md) for setup instructions for the compiler plugin.
> **Tip:**
-Storm exposes runtime metrics for template compilation, dirty checking, and entity cache behavior through JMX MBeans. See [Metrics](metrics.md) for details.
+Storm exposes runtime metrics for template compilation, dirty checking, and entity cache behavior through JMX MBeans; see [Metrics](metrics.md). For query and transaction metrics and tracing through your observability stack, add the `storm-micrometer` module (included in the Spring Boot starters).
---
@@ -14928,8 +15522,8 @@ data class Article(
@DynamicUpdate(FIELD)
record Article(
@PK Integer id,
- @Nonnull String title,
- @Nonnull String content
+ String title,
+ String content
) implements Entity {}
```
### Dirty Check Strategy Per Entity
@@ -15011,7 +15605,7 @@ java -Dstorm.validation.schema_mode=fail \
- `storm.validation.schema_mode=fail` catches entity-to-schema mismatches at startup rather than at runtime.
- `storm.validation.interpolation_mode=fail` prevents execution of templates that were not processed by the compiler plugin and do not use explicit `t()` calls, protecting against accidental SQL injection.
-During development, the defaults (`schema_mode=none`, `interpolation_mode=warn`) provide a smoother experience: schema validation is skipped (since the schema may be evolving), and missing compiler plugin usage is logged as a warning rather than blocking execution.
+In the Spring Boot starter and Ktor plugin, `schema_mode` already defaults to `fail`, so entity-to-schema mismatches abort startup out of the box; relax it to `warn` or `none` while a schema is still evolving. `interpolation_mode` defaults to `warn`, so missing compiler plugin usage is logged rather than blocking execution until you opt into `fail`.
========================================
@@ -15248,6 +15842,9 @@ No additional configuration or dependencies are needed beyond the Storm dependen
Storm exposes runtime metrics through JMX (Java Management Extensions) MBeans. These metrics give you visibility into template compilation performance, dirty checking behavior, and entity cache efficiency. All MBeans are registered automatically when Storm initializes and aggregate across all `ORMTemplate` instances in the JVM.
+> **Tip:**
+The metrics on this page are JMX-based and always on. For metrics and tracing through your observability stack, the `storm-micrometer` module reports every query and transaction as Micrometer Observations; see the observability sections of [Spring Integration](spring-integration.md#observability) and [Ktor Integration](ktor-integration.md#observability).
+
To view these metrics, connect to the JVM with any JMX client (JConsole, VisualVM, or your monitoring platform) and navigate to the `st.orm` domain. If your application uses Spring Boot Actuator, the MBeans are also accessible through Actuator's JMX endpoint.
---
@@ -15817,7 +16414,7 @@ public class WriteAccessCallback implements EntityCallback {
When something goes wrong, Storm communicates the problem through a small, well-defined set of exception types. Understanding which exceptions can be thrown and when helps you write robust error handling that distinguishes between recoverable situations (like a missing entity) and programming mistakes (like a schema mismatch).
-This page covers Storm's exception hierarchy, the most common error scenarios you will encounter, and strategies for diagnosing problems when they arise.
+This page covers Storm's exception hierarchy, the most common error scenarios you will encounter, and strategies for diagnosing problems when they arise. For mapping these exceptions to HTTP responses in a Ktor application, see the StatusPages recipes in [Ktor Integration](ktor-integration.md#error-handling).
---
@@ -17118,14 +17715,15 @@ The following tables provide a side-by-side comparison of concrete features acro
### Entity & Data Modeling
-| Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Exposed | Ktorm |
-|---------|-------|-----|-------------|---------|------|------|---------|-------|
-| Lines per entity | ~5 | ~301 | ~301 | ~20+ | Generated | ~15 | ~12 | ~15 |
-| Immutable entities | Yes | No | No | Yes | Yes | Yes | DSL only | No |
-| Polymorphism | Yes2 | Yes | Via JPA | No | No | No | No | No |
-| Automatic relationships | Yes | Yes3 | Via JPA | No | No | No | DAO only | No |
-| Cascade persist | No | Yes | Yes | No | No | No | No | No |
-| Lifecycle callbacks | Yes | Yes | Via JPA | No | Yes | No | DAO only | No |
+| Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Jimmer | Exposed | Ktorm |
+|---------|-------|-----|-------------|---------|------|------|--------|---------|-------|
+| 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 | Write sets10 | Yes | Yes | No | No | No | Yes | No | No |
+| Lifecycle callbacks | Yes | Yes | Via JPA | No | Yes | No | Yes | DAO only | No |
1 JPA/Spring Data lines without Lombok; ~10 lines with Lombok.
@@ -17133,32 +17731,39 @@ The following tables provide a side-by-side comparison of concrete features acro
3 JPA relationships are runtime-managed via proxies.
+8 Jimmer supports `@MappedSuperclass` for sharing fields across entities, but not JPA-style single-table, joined, or table-per-class inheritance strategies.
+
+10 Storm persists entity graphs through [write sets](write-sets.md): dependency-ordered, mixed-type batch writes with generated-key propagation, declared explicitly at the call site (`orm.writeSet()`) rather than configured on the mapping.
+
### Querying & Data Access
-| Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Exposed | Ktorm |
-|---------|-------|-----|-------------|---------|------|------|---------|-------|
-| Type-safe queries | Yes | Criteria | No | No | Yes | No | Yes | Yes |
-| SQL Templates | Yes | No | No | XML/Ann | Yes | Yes | No | No |
-| N+1 prevention | Yes | No | No | No | Manual | Manual | No | No |
-| Lazy loading | Refs | Yes | Yes | No | No | No | Yes | Yes |
-| Scrolling | Yes | No | Yes | No | Yes | No | No | No |
-| JSON columns | Yes | Yes4 | Via JPA | Manual | Yes | Module | Yes | Module |
-| JSON aggregation | Yes | No | No | No | Yes | No | No | No |
+| Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Jimmer | Exposed | Ktorm |
+|---------|-------|-----|-------------|---------|------|------|--------|---------|-------|
+| Type-safe queries | Yes | Criteria | No | No | Yes | No | Yes | Yes | Yes |
+| 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 |
+| JSON aggregation | Yes | No | No | No | Yes | No | No | No | No |
4 JPA requires Hibernate 6.2+ for built-in JSON support; older versions need a third-party library or custom `AttributeConverter`.
+9 Jimmer has no SQL template engine, but native SQL fragments can be embedded in its type-safe DSL via `sql(...)`.
+
### Runtime & Ecosystem
-| Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Exposed | Ktorm |
-|---------|-------|-----|-------------|---------|------|------|---------|-------|
-| Transactions | Both | Both | Declarative | Both | Programmatic | Both | Both6 | Required |
-| Schema validation | Yes | Yes | Via JPA | No | N/A5 | No | Yes | No |
-| Java support | Yes | Yes | Yes | Yes | Yes | Yes | No | No |
-| Kotlin support | First-class | Good | Good | Good | Good | Good | Native | Native |
-| Coroutines | Yes | No | No | No | No | No | Yes | Limited |
-| Spring integration | Yes | Yes | Native | Yes | Yes | Yes | Yes | Yes |
-| Runtime mechanism | Codegen7 | Bytecode | Bytecode | Reflection | Codegen | Reflection | Reflection | Reflection |
-| Community | New | Huge | Huge | Large | Medium | Medium | Medium | Small |
+| Feature | Storm | JPA | Spring Data | MyBatis | jOOQ | JDBI | Jimmer | Exposed | Ktorm |
+|---------|-------|-----|-------------|---------|------|------|--------|---------|-------|
+| Transactions | Both | Both | Declarative | Both | Programmatic | Both | Both | Both6 | Required |
+| Schema validation | Yes | Yes | Via JPA | No | N/A5 | No | No | Yes | No |
+| Java support | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | No |
+| Kotlin support | First-class | Good | Good | Good | Good | Good | First-class | Native | Native |
+| Coroutines | Yes | No | No | No | No | No | No | Yes | Limited |
+| Spring integration | Yes | Yes | Native | Yes | Yes | Yes | Yes | Yes | Yes |
+| Runtime mechanism | Codegen7 | Bytecode | Bytecode | Reflection | Codegen | Reflection | Codegen | Reflection | Reflection |
+| Community | New | Huge | Huge | Large | Medium | Medium | Small | Medium | Small |
5 jOOQ generates code from the database schema, so schema validation is inherent in its code generation step.
@@ -17178,6 +17783,7 @@ JPA (typically implemented by Hibernate) is the most widely used persistence fra
| **Polymorphism** | Sealed types (Single-Table, Joined, Polymorphic FK); STRING, INTEGER, CHAR discriminators | Class hierarchy (Single-Table, Joined, Table-per-Class); STRING, INTEGER, CHAR discriminators |
| **State** | Stateless; no persistence context | Managed entities |
| **Loading** | Loading in single query | Lazy loading common |
+| **Graph persistence** | Write sets: explicit, dependency-ordered, call-local | Cascade annotations on mappings |
| **N+1 Problem** | Prevented by design; requires explicit opt-in | Common pitfall |
| **Queries** | Type-safe DSL, SQL Templates | JPQL, Criteria API |
| **Caching** | Transaction-scoped observation | First/second level cache |
@@ -17322,6 +17928,80 @@ JDBI is a lightweight SQL convenience library that sits just above JDBC. It hand
- You prefer minimal abstraction
- You have mostly complex queries that don't fit ORM patterns
+## Storm vs Jimmer
+
+Jimmer is a modern Kotlin and Java ORM that, like Storm, is built on immutable entities and a stateless model with no persistence context, and it eliminates the N+1 problem by design. The two frameworks share a great deal of philosophy. Where they differ is conciseness and concept count. Jimmer trades some concision for GraphQL-style dynamic object fetching: entities are interfaces you interact with through generated drafts, reading a foreign-key id without loading the association requires an extra `@IdView` property, and every query ends in an explicit `select` with an object fetcher. That fetcher, together with Jimmer's dedicated DTO language, is a genuine strength for shape-controlled API responses. Storm keeps the model a plain data class and the query a single line, and treats any data class as a result type.
+
+Defining an entity:
+
+```kotlin
+// Jimmer
+@Entity
+interface Book {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ val id: Long
+ val name: String
+ val price: BigDecimal
+
+ @ManyToOne
+ val store: BookStore?
+
+ @IdView("store") // extra property to read the FK id without loading
+ val storeId: Long?
+}
+
+// Storm — book.store.id is already available, so no extra property is needed
+data class Book(
+ @PK val id: Long = 0,
+ val name: String,
+ val price: BigDecimal,
+ @FK val store: BookStore?
+) : Entity
+```
+
+Loading a book together with its store:
+
+```kotlin
+// Jimmer — explicit query and object fetcher
+val books = sqlClient.createQuery(Book::class) {
+ where(table.store.name eq "Manning")
+ select(table.fetchBy {
+ allScalarFields()
+ store { allScalarFields() }
+ })
+}.execute()
+
+// Storm — the store is loaded in the same query
+val books = books.findAll(Book_.store.name eq "Manning")
+```
+
+| Aspect | Storm | Jimmer |
+|--------|-------|--------|
+| **Entities** | Immutable data classes, constructed directly | Immutable interfaces, constructed and copied via generated drafts |
+| **Foreign-key id** | `book.store.id`, always available | Declare an extra `@IdView` property per association |
+| **Simple query** | `findAll(Book_.store.name eq "...")` | `createQuery { where(...); select(table.fetchBy { ... }) }.execute()` |
+| **Result shaping** | Any data class is a result type | Object fetchers and a dedicated `.dto` language |
+| **N+1 Problem** | Prevented via single joined query | Prevented via batched separate queries (DataLoader-style) |
+| **State** | Stateless; no persistence context | Stateless; no persistence context |
+| **Caching** | Transaction-scoped identity cache | Multi-level cache with automatic invalidation |
+| **Languages** | Kotlin + Java | Kotlin + Java |
+| **License** | Apache 2.0 | Apache 2.0 |
+
+### When to Choose Storm
+
+- You want the smallest possible entity model and one-line queries
+- Your reads mostly map to entities and projections
+- You prefer plain data classes over interfaces and generated drafts
+- You want relationships loaded in a single query
+
+### When to Choose Jimmer
+
+- You want GraphQL-style dynamic object fetching with precise shape control
+- Its DTO language for typed, reusable projections fits your API contracts
+- You rely on its multi-level caching with automatic invalidation
+- You want to save arbitrary object graphs with its save command
+
---
## Kotlin-Only Frameworks
@@ -17496,6 +18176,7 @@ Ready to try it? See the [Getting Started](getting-started.md) guide.
- [JDBI](https://jdbi.org/)
- [Exposed](https://github.com/JetBrains/Exposed)
- [Ktorm](https://www.ktorm.org/)
+- [Jimmer](https://github.com/babyfish-ct/jimmer)
========================================
@@ -17606,7 +18287,7 @@ var updated = new User(user.id(), "new@example.com", user.name(), user.city());
**Custom wither methods:** Define `with*` methods on the record that return a new instance with a single field changed. Clean API but requires a method per field.
```java
-record User(@PK Integer id, @Nonnull String email, @Nonnull String name, @FK City city
+record User(@PK Integer id, String email, String name, @FK City city
) implements Entity {
User withEmail(String email) { return new User(id, email, name, city); }
}
@@ -18159,8 +18840,8 @@ Storm derives the table name (`user`) from the class name and column names (`ema
```java
record User(
@PK Long id,
- @Nonnull String email,
- @Nonnull String name,
+ String email,
+ String name,
@Nullable @FK City city,
@Nullable LocalDateTime createdAt
) implements Entity {}
@@ -18867,7 +19548,7 @@ AI works better when framework behavior is explicit and visible in source code.
Traditional ORMs rely on mechanisms that are powerful but implicit: proxy objects that intercept field access, lazy loading that triggers queries at unpredictable moments, persistence contexts that track entity state across transaction boundaries, and cascading rules that propagate changes through the object graph. These features serve real purposes, but they make AI-assisted development harder. The AI has to account for behavior that does not appear in the code. Code that compiles and looks correct can still break at runtime because of invisible framework state.
-Storm eliminates all of that. Entities are plain Kotlin data classes or Java records. There are no proxies, no managed state, no persistence context, and no lazy loading. Queries are explicit, and what you see in the source code is exactly what happens at runtime. This makes Storm's behavior predictable for AI tools: the code is the complete picture.
+Storm eliminates all of that. Entities are plain Kotlin data classes or Java records. There are no proxies, no managed state, no persistence context, and no transparent lazy loading: deferred loading is explicit, through `Ref`. Queries are explicit, and what you see in the source code is exactly what happens at runtime. This makes Storm's behavior predictable for AI tools: the code is the complete picture.
The design choices that matter most:
@@ -19113,7 +19794,7 @@ The Kotlin API does not depend on any preview features. All APIs are stable and
### storm-kotlin-spring
-Spring Framework integration for Kotlin. Provides `RepositoryBeanFactoryPostProcessor` for repository auto-discovery and injection, `@EnableTransactionIntegration` for bridging Storm's programmatic transactions with Spring's `@Transactional`, and transaction-aware coroutine support. Add this module when you use Spring Framework without Spring Boot.
+Spring Framework integration for Kotlin. Provides `RepositoryBeanFactoryPostProcessor` for repository auto-discovery and injection, and `springOrmTemplate` for composing a template that bridges Storm's programmatic transactions with Spring's `@Transactional`. Add this module when you use Spring Framework without Spring Boot.
```kotlin
implementation("st.orm:storm-kotlin-spring:@@STORM_VERSION@@")
diff --git a/website/static/skills/storm-repository-java.md b/website/static/skills/storm-repository-java.md
index 9d38c5a0a..6bbc4f282 100644
--- a/website/static/skills/storm-repository-java.md
+++ b/website/static/skills/storm-repository-java.md
@@ -247,6 +247,34 @@ List found = users.findAllById(List.of(1, 2, 3));
List found = users.findAllByRef(List.of(ref1, ref2));
```
+## Write Sets (Mixed-Type Graphs)
+
+Apply one write operation to entities of multiple types. Storm orders the writes by foreign-key
+dependencies, batches per type per dependency level, and propagates generated keys. For insert and
+upsert, unsaved entities held in the passed entities' foreign-key fields are discovered and
+inserted automatically (the insertion closure). Children link to a new parent by holding the same
+instance; two equal but distinct unsaved instances describe two rows.
+
+```java
+var owner = new Owner("Alice", "Bond", address); // unsaved
+var wolfie = new Pet("Wolfie", date, dog, owner); // same owner instance
+var rex = new Pet("Rex", date, dog, owner);
+var visit = new Visit(today, "Check-up", wolfie);
+
+orm.writeSet().insert(List.of(wolfie, rex, visit)); // owner discovered and inserted first
+
+// Typed single-root variant: whole graph in, keyed root out
+Visit fetched = orm.writeSet().insertAndFetch(visit);
+
+// update/remove write only the entities passed, no discovery; children are removed before parents
+orm.writeSet().update(List.of(changedOwner, changedPet));
+orm.writeSet().remove(List.of(visit, pet, owner));
+```
+
+An entity is unsaved when its primary key is the default value on an auto-generated key. Wrap
+write sets in a transaction when atomicity across the calls is required. Also available on
+repositories: `users.writeSet()` (delegates to the template; not scoped to the repository's type).
+
## Stream-Based Operations
Use Java `Stream` for memory-efficient processing of large datasets. **ALWAYS use try-with-resources** to avoid connection leaks:
diff --git a/website/static/skills/storm-repository-kotlin.md b/website/static/skills/storm-repository-kotlin.md
index abe7ca1b6..9e2b3e721 100644
--- a/website/static/skills/storm-repository-kotlin.md
+++ b/website/static/skills/storm-repository-kotlin.md
@@ -351,6 +351,39 @@ users.upsert(listOf(user1, user2))
val upserted: List = users.upsertAndFetch(listOf(user1, user2))
```
+## Write Sets (Mixed-Type Graphs)
+
+Apply one write operation to entities of multiple types. Storm orders the writes by foreign-key
+dependencies, batches per type per dependency level, and propagates generated keys. For insert and
+upsert, unsaved entities held in the passed entities' foreign-key fields are discovered and
+inserted automatically (the insertion closure). Children link to a new parent by holding the same
+instance; two equal but distinct unsaved instances describe two rows.
+
+```kotlin
+val owner = Owner(firstName = "Alice", lastName = "Bond", address = address) // unsaved
+val wolfie = Pet(name = "Wolfie", birthDate = date, type = dog, owner = owner) // same owner instance
+val rex = Pet(name = "Rex", birthDate = date, type = dog, owner = owner)
+val visit = Visit(visitDate = today, description = "Check-up", pet = wolfie)
+
+orm.writeSet().insert(listOf(wolfie, rex, visit)) // owner discovered and inserted first
+
+// Typed single-root variant: whole graph in, keyed root out
+val fetched: Visit = orm.writeSet().insertAndFetch(visit)
+
+// Scoped block; each verb executes immediately, wrap in a transaction for atomicity
+transaction {
+ orm.writeSet {
+ insert(newPets)
+ update(changedOwners) // update/remove write only the entities passed, no discovery
+ remove(staleVisits) // children are removed before parents
+ }
+}
+```
+
+An entity is unsaved when its primary key is the default value on an auto-generated key. Also
+available on repositories: `users.writeSet()` (delegates to the template; not scoped to the
+repository's type).
+
## Flow-Based Streaming
Use Kotlin `Flow` for memory-efficient processing of large datasets:
From 3a4f063c76f027609d98425f0e92ba9304b09a6c Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort
Date: Wed, 15 Jul 2026 01:24:14 +0200
Subject: [PATCH 2/3] feat: propagate generated keys into composite key
carriers for junction tables
A non-insertable FK component whose column value is carried by an
insertable component of a composite primary key (the junction table
pattern) can now join the insertion closure: the generated key is
written into the carrying key component instead of failing fast.
---
docs/relationships.md | 2 +-
docs/write-sets.md | 66 ++++++-
.../core/repository/impl/WriteSetImpl.java | 185 ++++++++++++++++--
...itoryPreparedStatementIntegrationTest.java | 7 +-
.../st/orm/core/WriteSetIntegrationTest.java | 99 +++++++++-
storm-core/src/test/resources/data.sql | 3 +
.../src/main/java/st/orm/WriteSet.java | 12 +-
7 files changed, 349 insertions(+), 25 deletions(-)
diff --git a/docs/relationships.md b/docs/relationships.md
index bc476af9b..b100f85cf 100644
--- a/docs/relationships.md
+++ b/docs/relationships.md
@@ -215,7 +215,7 @@ data class UserRole(
) : Entity
```
-The `@Persist(insertable = false, updatable = false)` annotation indicates that the FK columns overlap with the composite PK columns. The FK fields are used to load the related entities, but the column values come from the PK during insert/update operations.
+The `@Persist(insertable = false, updatable = false)` annotation indicates that the FK columns overlap with the composite PK columns. The FK fields are used to load the related entities, but the column values come from the PK during insert/update operations. [Write sets](write-sets.md#junction-tables) recognize this shape: a junction row referencing an unsaved entity is inserted after its parent, with the generated key propagated into the composite PK.
Query through the join entity:
diff --git a/docs/write-sets.md b/docs/write-sets.md
index 97e788a5e..479e6b335 100644
--- a/docs/write-sets.md
+++ b/docs/write-sets.md
@@ -142,6 +142,70 @@ orm.writeSet().insert(List.of(pet)); // owner is inserted first, the ref bind
Id-only refs such as `Ref.of(Owner::class, 42)` are pointers to known rows: they bind as foreign key values and are never written. An id-only ref carrying a default id cannot describe a new entity and fails fast. Note that ref equality is based on type and id, so refs wrapping distinct unsaved instances compare equal until the instances are persisted; do not use unsaved refs as map keys.
+## Junction Tables
+
+The [many-to-many pattern](relationships.md#many-to-many) models a junction table with a composite primary key holding the key columns, and non-insertable foreign key fields that load the related entities:
+
+
+
+
+```kotlin
+data class UserRolePk(
+ val userId: Int,
+ val roleId: Int
+)
+
+data class UserRole(
+ @PK val userRolePk: UserRolePk,
+ @FK @Persist(insertable = false, updatable = false) val user: User,
+ @FK @Persist(insertable = false, updatable = false) val role: Role
+) : Entity
+```
+
+
+
+
+```java
+record UserRolePk(int userId, int roleId) {}
+
+record UserRole(@PK UserRolePk userRolePk,
+ @FK @Persist(insertable = false, updatable = false) User user,
+ @FK @Persist(insertable = false, updatable = false) Role role
+) implements Entity {}
+```
+
+
+
+
+Write sets recognize this shape. A non-insertable foreign key field whose column value is carried by an insertable component of the primary key participates in the insertion closure like any other edge: an unsaved entity held by the field is discovered and inserted first, and its generated key is written into the carrying key component before the junction row is inserted. The key components for already-persisted entities are set as usual, so mixed rows work naturally:
+
+
+
+
+```kotlin
+val user = User(name = "Alice") // unsaved
+val role = orm.entity().findByName("admin") // saved
+val userRole = UserRole(UserRolePk(user.id, role.id), user, role)
+
+orm.writeSet().insert(listOf(userRole)) // user is inserted first; its key lands in userRolePk.userId
+```
+
+
+
+
+```java
+var user = new User("Alice"); // unsaved
+var role = orm.entity(Role.class).findByName("admin"); // saved
+var userRole = new UserRole(new UserRolePk(user.id(), role.id()), user, role);
+
+orm.writeSet().insert(List.of(userRole)); // user is inserted first; its key lands in userRolePk.userId
+```
+
+
+
+
+The unsaved entity's default id in the key component is a placeholder; the write set overwrites it with the generated key. Only when no insertable primary key component carries the column value does the reference fail fast (see below).
+
## Update, Upsert and Remove
The remaining verbs follow the same contract, each with the ordering that suits it:
@@ -174,7 +238,7 @@ Write sets keep Storm's fail-fast doctrine. Each of the following raises a descr
- A dependency cycle that cannot be executed by the dependency-ordering strategy. The write set does not break cycles using nullable intermediate values, deferred constraints, or follow-up updates.
- An id-only ref with a default id in an insert or upsert set (it cannot describe a new entity; wrap the instance instead).
- An unsaved entity passed to update or remove.
-- An unsaved entity referenced through a non-insertable foreign key component (typically a junction table whose key columns live inside a composite primary key).
+- An unsaved entity referenced through a non-insertable foreign key component whose column value is not carried by an insertable primary key component (junction tables carry their key columns inside the composite primary key and do participate; see [Junction Tables](#junction-tables)).
References to entities outside the effective write set follow the normal repository rules: keyed references only provide foreign key values and are never written; unsaved references fail wherever their key is required as a foreign key value.
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 2e5876bf9..2289fa71b 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
@@ -19,6 +19,7 @@
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
+import java.lang.invoke.MethodType;
import java.lang.reflect.ParameterizedType;
import java.util.ArrayDeque;
import java.util.ArrayList;
@@ -52,7 +53,9 @@
*
* Resolves the dependency graph of the passed entities, partitions it into topological levels, and delegates each
* level to the existing per-type repository batch operations. Generated primary keys are propagated to dependent
- * records by rebuilding the immutable records with the keyed instances, correlated by instance identity.
+ * records by rebuilding the immutable records with the keyed instances, correlated by instance identity. For
+ * non-insertable FK components whose column value is carried by a component of the primary key (the junction table
+ * pattern), the generated key is additionally written into the carrying key component.
*
* Instances are stateless per call (all resolution state is method-local; the per-type metadata cache is
* concurrent) and can safely be shared across threads.
@@ -173,11 +176,11 @@ private Execution executeOrdered(@Nonnull Iterable extends Entity>> entities
if (target == null || !isUnsaved(target)) {
continue;
}
- if (!edge.insertable) {
+ if (!edge.insertable && edge.keyPath == null) {
throw new PersistenceException(("Foreign key component '%s.%s' is not insertable but references " +
- "an unsaved %s. Its column value is persisted elsewhere (typically inside the primary " +
- "key), so the write set cannot propagate the generated key. Persist the %s first and set " +
- "its id explicitly.").formatted(
+ "an unsaved %s, and no insertable primary key component carries its column value, so " +
+ "the write set cannot propagate the generated key. Persist the %s first and set its id " +
+ "explicitly.").formatted(
node.entity.getClass().getSimpleName(), edge.name,
target.getClass().getSimpleName(), target.getClass().getSimpleName()));
}
@@ -195,12 +198,15 @@ private Execution executeOrdered(@Nonnull Iterable extends Entity>> entities
// instance. Such references carry no key propagation, but the referenced row must be written first to
// satisfy foreign key constraints. A key is preserved when it is not generated, or when the member receives
// upsert semantics: an upsert matches on the provided key instead of generating a new one, so an explicitly
- // passed keyed member of an upsert set is orderable even when its key column is auto-generated.
+ // passed keyed member of an upsert set is orderable even when its key column is auto-generated. A member
+ // whose key propagation still writes into its primary key (a junction row awaiting a parent's generated
+ // key) carries a transient key and is not registered.
Map keyedMembers = new HashMap<>();
for (Node node : discoveryOrder) {
TypeInfo info = typeInfo(node.entity.getClass());
boolean keyPreserved = !info.autoGeneratedPrimaryKey || (verb == Verb.UPSERT && node.passed);
- if (keyPreserved) {
+ if (keyPreserved && node.dependencies.stream().noneMatch(dependency ->
+ writesPrimaryKey(info, dependency.edge()))) {
Object id = ((Entity>) node.entity).id();
if (!REFLECTION.isDefaultValue(id)) {
keyedMembers.put(new TypeIdKey(node.entity.getClass(), id), node);
@@ -321,10 +327,28 @@ private Object propagateKeys(@Nonnull Node node, @Nonnull IdentityHashMap) persisted)
: persisted;
entity = withComponent(entity, edge.path, 0, newValue);
+ if (edge.keyPath != null) {
+ // The component's column value is carried by the primary key; write the generated key into the
+ // carrier so the insert binds it.
+ entity = withComponent(entity, edge.keyPath, 0, ((Entity>) persisted).id());
+ }
}
return entity;
}
+ /**
+ * Determines whether propagating the given edge rewrites the entity's primary key: either the edge itself is a
+ * primary key component (a key that is also a foreign key) or its generated key is carried by a primary key
+ * component (a junction row).
+ */
+ private static boolean writesPrimaryKey(@Nonnull TypeInfo info, @Nonnull FkEdge edge) {
+ if (info.primaryKeyIndex < 0) {
+ return false;
+ }
+ return edge.path[0] == info.primaryKeyIndex
+ || (edge.keyPath != null && edge.keyPath[0] == info.primaryKeyIndex);
+ }
+
//
// Update.
//
@@ -595,8 +619,14 @@ private List> fetch(@Nonnull Execution execution) {
// Per-type metadata.
//
- /** An FK component of an entity type: the component path from the root record, its kind and its target type. */
- private record FkEdge(int[] path, String name, boolean ref, Class extends Data> targetType, boolean insertable) {}
+ /**
+ * An FK component of an entity type: the component path from the root record, its kind and its target type.
+ * A non-insertable component whose column value is carried by an insertable component (typically a field of a
+ * composite primary key, as in a junction table) records the carrier's path as {@code keyPath}; generated keys
+ * propagate into the carrier rather than through the component itself.
+ */
+ private record FkEdge(int[] path, String name, String fieldPath, boolean ref, Class extends Data> targetType,
+ boolean insertable, @Nullable int[] keyPath) {}
private final class TypeInfo {
final Model, ?> model;
@@ -628,9 +658,129 @@ private final class TypeInfo {
}
this.primaryKeyIndex = primaryKeyFieldIndex;
List edges = new ArrayList<>();
- collectFkEdges(recordType, new ArrayList<>(), true, edges);
+ collectFkEdges(recordType, new ArrayList<>(), new ArrayList<>(), true, edges);
+ if (edges.stream().anyMatch(edge -> !edge.insertable)) {
+ resolveKeyCarriers(recordType, edges);
+ }
this.fkEdges = List.copyOf(edges);
}
+
+ /**
+ * Resolves, for each non-insertable FK component, the insertable component that carries its column value:
+ * the primary key field whose column shares the FK component's column name, as in a junction table whose
+ * key columns live inside a composite primary key. Components with a carrier can join the insertion
+ * closure; the generated key is propagated into the carrier instead of through the component itself.
+ */
+ private void resolveKeyCarriers(@Nonnull RecordType recordType, @Nonnull List edges) {
+ if (primaryKeyIndex < 0) {
+ return;
+ }
+ List columns = model.declaredColumns();
+ List primaryKeyColumns = columns.stream()
+ .filter(Column::primaryKey)
+ .filter(Column::insertable)
+ .toList();
+ List leafPaths = primaryKeyLeafPaths(recordType.fields().get(primaryKeyIndex), primaryKeyIndex);
+ if (leafPaths == null || leafPaths.size() != primaryKeyColumns.size()) {
+ return;
+ }
+ Map carrierByColumnName = new HashMap<>();
+ for (int i = 0; i < primaryKeyColumns.size(); i++) {
+ Column column = primaryKeyColumns.get(i);
+ RecordField leafField = fieldAt(recordType, leafPaths.get(i));
+ // The leaf fields correspond to the primary key columns by declaration order; a type mismatch
+ // indicates the correspondence does not hold, in which case no carriers are resolved.
+ if (!wrap(leafField.type()).equals(wrap(column.persistedType()))) {
+ return;
+ }
+ carrierByColumnName.put(column.name(), leafPaths.get(i));
+ }
+ // Group the FK columns by the component they belong to; a component mapping to multiple columns
+ // references a composite key, which cannot be carried by a single primary key field.
+ Map> foreignKeyColumnsByFieldPath = new HashMap<>();
+ for (Column column : columns) {
+ if (column.foreignKey()) {
+ foreignKeyColumnsByFieldPath
+ .computeIfAbsent(column.metamodel().fieldPath(), fieldPath -> new ArrayList<>())
+ .add(column);
+ }
+ }
+ for (int i = 0; i < edges.size(); i++) {
+ FkEdge edge = edges.get(i);
+ if (edge.insertable) {
+ continue;
+ }
+ List edgeColumns = foreignKeyColumnsByFieldPath.get(edge.fieldPath);
+ if (edgeColumns == null || edgeColumns.size() != 1) {
+ continue;
+ }
+ int[] carrier = carrierByColumnName.get(edgeColumns.getFirst().name());
+ if (carrier != null) {
+ edges.set(i, new FkEdge(edge.path, edge.name, edge.fieldPath, edge.ref, edge.targetType,
+ false, carrier));
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns the paths of the scalar leaf components of the primary key field, in declaration order, or
+ * {@code null} when the key contains entity, ref or FK components: such keys carry their own edges and need
+ * no carrier resolution.
+ */
+ @Nullable
+ private static List primaryKeyLeafPaths(@Nonnull RecordField primaryKeyField, int primaryKeyIndex) {
+ List leaves = new ArrayList<>();
+ List path = new ArrayList<>();
+ path.add(primaryKeyIndex);
+ if (!collectScalarLeaves(primaryKeyField, path, leaves)) {
+ return null;
+ }
+ return leaves;
+ }
+
+ private static boolean collectScalarLeaves(@Nonnull RecordField field,
+ @Nonnull List path,
+ @Nonnull List leaves) {
+ if (field.isAnnotationPresent(FK.class)
+ || Entity.class.isAssignableFrom(field.type())
+ || Ref.class.isAssignableFrom(field.type())) {
+ return false;
+ }
+ var nested = REFLECTION.findRecordType(field.type());
+ if (nested.isEmpty()) {
+ leaves.add(toArray(path));
+ return true;
+ }
+ List fields = nested.get().fields();
+ for (int i = 0; i < fields.size(); i++) {
+ path.add(i);
+ boolean scalar = collectScalarLeaves(fields.get(i), path, leaves);
+ path.remove(path.size() - 1);
+ if (!scalar) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Returns the record field at the given component path, descending through nested records. */
+ private static RecordField fieldAt(@Nonnull RecordType rootType, @Nonnull int[] path) {
+ RecordType current = rootType;
+ RecordField field = null;
+ for (int index : path) {
+ field = current.fields().get(index);
+ current = REFLECTION.findRecordType(field.type()).orElse(null);
+ }
+ return requireNonNull(field, "field");
+ }
+
+ /** Returns the wrapper type for primitives, the type itself otherwise. */
+ private static Class> wrap(@Nonnull Class> type) {
+ if (!type.isPrimitive()) {
+ return type;
+ }
+ return MethodType.methodType(type).wrap().returnType();
}
private TypeInfo typeInfo(@Nonnull Class> type) {
@@ -644,6 +794,7 @@ private TypeInfo typeInfo(@Nonnull Class> type) {
*/
private void collectFkEdges(@Nonnull RecordType recordType,
@Nonnull List path,
+ @Nonnull List nameParts,
boolean insertable,
@Nonnull List edges) {
List fields = recordType.fields();
@@ -653,16 +804,20 @@ private void collectFkEdges(@Nonnull RecordType recordType,
boolean fieldInsertable = insertable && (persist == null || persist.insertable());
if (field.isAnnotationPresent(FK.class)) {
path.add(i);
+ nameParts.add(field.name());
+ String fieldPath = String.join(".", nameParts);
if (Ref.class.isAssignableFrom(field.type())) {
// Only refs to entities can act as write-set edges; refs to projections merely bind their id.
Class extends Data> targetType = refTargetType(field);
if (Entity.class.isAssignableFrom(targetType)) {
- edges.add(new FkEdge(toArray(path), field.name(), true, targetType, fieldInsertable));
+ edges.add(new FkEdge(toArray(path), field.name(), fieldPath, true, targetType,
+ fieldInsertable, null));
}
} else if (field.isDataType() && Entity.class.isAssignableFrom(field.type())) {
- edges.add(new FkEdge(toArray(path), field.name(), false,
- (Class extends Data>) field.type(), fieldInsertable));
+ edges.add(new FkEdge(toArray(path), field.name(), fieldPath, false,
+ (Class extends Data>) field.type(), fieldInsertable, null));
}
+ nameParts.remove(nameParts.size() - 1);
path.remove(path.size() - 1);
continue;
}
@@ -674,7 +829,9 @@ private void collectFkEdges(@Nonnull RecordType recordType,
var nested = REFLECTION.findRecordType(field.type());
if (nested.isPresent()) {
path.add(i);
- collectFkEdges(nested.get(), path, fieldInsertable, edges);
+ nameParts.add(field.name());
+ collectFkEdges(nested.get(), path, nameParts, fieldInsertable, edges);
+ nameParts.remove(nameParts.size() - 1);
path.remove(path.size() - 1);
}
}
diff --git a/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java b/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java
index e74296f77..323c9facc 100644
--- a/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java
+++ b/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java
@@ -334,12 +334,15 @@ public void testInsert() {
@Test
public void testInsertReturningIds() {
- // data.sql inserts 6 vets (ids 1-6 via auto_increment). Next two get ids 7 and 8.
+ // The concrete id values depend on the tests that ran before, because auto_increment state survives the
+ // per-test rollback; the contract under test is that the generated ids are returned in input order.
var repository = ORMTemplate.of(dataSource).entity(Vet.class);
Vet vet1 = Vet.builder().firstName("Noel").lastName("Fitzpatrick").build();
Vet vet2 = Vet.builder().firstName("Scarlett").lastName("Magda").build();
var ids = repository.insertAndFetchIds(List.of(vet1, vet2));
- assertEquals(List.of(7, 8), ids);
+ assertEquals(2, ids.size());
+ assertEquals("Noel", repository.findById(ids.get(0)).orElseThrow().firstName());
+ assertEquals("Scarlett", repository.findById(ids.get(1)).orElseThrow().firstName());
}
@Test
diff --git a/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
index 919fc5988..81304dc16 100644
--- a/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
+++ b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
@@ -6,6 +6,7 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import jakarta.annotation.Nonnull;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@@ -16,7 +17,11 @@
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
+import st.orm.DbTable;
import st.orm.Entity;
+import st.orm.FK;
+import st.orm.PK;
+import st.orm.Persist;
import st.orm.PersistenceException;
import st.orm.Ref;
import st.orm.core.model.Address;
@@ -201,13 +206,101 @@ public void testInsertIdOnlyRefWithDefaultIdFails() {
}
@Test
- public void testInsertUnsavedThroughNonInsertableComponentFails() {
+ public void testInsertJunctionPropagatesGeneratedKeyIntoCompositePk() {
var orm = orm();
+ var vet = Vet.builder().firstName("New").lastName("JunctionVet").build();
var vetSpecialty = new VetSpecialty(
new VetSpecialtyPK(0, 1),
- Vet.builder().firstName("New").lastName("Vet").build(),
+ vet,
Specialty.builder().id(1).name("radiology").build());
- var exception = assertThrows(PersistenceException.class, () -> orm.writeSet().insert(List.of(vetSpecialty)));
+ List inserts = new ArrayList<>();
+ SqlInterceptor.observe(
+ sql -> { if (sql.statement().toUpperCase().startsWith("INSERT")) inserts.add(sql.statement()); },
+ () -> { orm.writeSet().insert(List.of(vetSpecialty)); return null; });
+ // The vet joins via the insertion closure and is written first; its generated key is carried by the
+ // junction row's composite primary key.
+ assertEquals(2, inserts.size());
+ assertTrue(inserts.get(1).contains("vet_specialty"));
+ var insertedVets = orm.entity(Vet.class).select().getResultList().stream()
+ .filter(candidate -> "JunctionVet".equals(candidate.lastName()))
+ .toList();
+ assertEquals(1, insertedVets.size());
+ var junction = orm.entity(VetSpecialty.class).findById(new VetSpecialtyPK(insertedVets.getFirst().id(), 1));
+ assertTrue(junction.isPresent());
+ assertEquals("radiology", junction.get().specialty().name());
+ }
+
+ @Test
+ public void testInsertJunctionRowsSharingUnsavedParent() {
+ var orm = orm();
+ var vet = Vet.builder().firstName("Shared").lastName("JunctionParent").build();
+ var radiology = new VetSpecialty(new VetSpecialtyPK(0, 1), vet,
+ Specialty.builder().id(1).name("radiology").build());
+ var surgery = new VetSpecialty(new VetSpecialtyPK(0, 2), vet,
+ Specialty.builder().id(2).name("surgery").build());
+ List inserts = new ArrayList<>();
+ SqlInterceptor.observe(
+ sql -> { if (sql.statement().toUpperCase().startsWith("INSERT")) inserts.add(sql.statement()); },
+ () -> { orm.writeSet().insert(List.of(radiology, surgery)); return null; });
+ // The shared vet instance is inserted exactly once; the junction rows form a single batch.
+ assertEquals(2, inserts.size());
+ var insertedVets = orm.entity(Vet.class).select().getResultList().stream()
+ .filter(candidate -> "JunctionParent".equals(candidate.lastName()))
+ .toList();
+ assertEquals(1, insertedVets.size());
+ int vetId = insertedVets.getFirst().id();
+ assertTrue(orm.entity(VetSpecialty.class).findById(new VetSpecialtyPK(vetId, 1)).isPresent());
+ assertTrue(orm.entity(VetSpecialty.class).findById(new VetSpecialtyPK(vetId, 2)).isPresent());
+ }
+
+ @Test
+ public void testInsertJunctionRowsWithEqualTransientKeys() {
+ var orm = orm();
+ // Both junction rows carry the same transient key (0, 1) until their parents' keys are propagated; the
+ // write set correlates by instance identity, so each row binds its own parent.
+ var first = new VetSpecialty(new VetSpecialtyPK(0, 1),
+ Vet.builder().firstName("First").lastName("TransientKey").build(),
+ Specialty.builder().id(1).name("radiology").build());
+ var second = new VetSpecialty(new VetSpecialtyPK(0, 1),
+ Vet.builder().firstName("Second").lastName("TransientKey").build(),
+ Specialty.builder().id(1).name("radiology").build());
+ orm.writeSet().insert(List.of(first, second));
+ var vets = orm.entity(Vet.class).select().getResultList().stream()
+ .filter(candidate -> "TransientKey".equals(candidate.lastName()))
+ .toList();
+ assertEquals(2, vets.size());
+ for (var vet : vets) {
+ assertTrue(orm.entity(VetSpecialty.class).findById(new VetSpecialtyPK(vet.id(), 1)).isPresent());
+ }
+ }
+
+ @Test
+ public void testInsertAndFetchJunctionReturnsCompleteKey() {
+ var orm = orm();
+ var vet = Vet.builder().firstName("Fetched").lastName("JunctionVet").build();
+ var vetSpecialty = new VetSpecialty(new VetSpecialtyPK(0, 3), vet,
+ Specialty.builder().id(3).name("dentistry").build());
+ var fetched = orm.writeSet().insertAndFetch(vetSpecialty);
+ assertNotEquals(0, fetched.id().vetId());
+ assertEquals(fetched.id().vetId(), (int) fetched.vet().id());
+ assertEquals("Fetched", fetched.vet().firstName());
+ assertEquals(3, fetched.id().specialtyId());
+ }
+
+ @DbTable("vet_badge")
+ public record VetBadge(
+ @PK Integer id,
+ @Nonnull String label,
+ @Nonnull @FK @Persist(insertable = false, updatable = false) Vet vet
+ ) implements Entity {}
+
+ @Test
+ public void testInsertUnsavedThroughNonInsertableComponentWithoutCarrierFails() {
+ var orm = orm();
+ // The badge's vet_id column is neither insertable nor carried by a primary key component, so an unsaved
+ // vet behind it cannot join the insertion closure.
+ var badge = new VetBadge(null, "Unsupported", Vet.builder().firstName("New").lastName("Vet").build());
+ var exception = assertThrows(PersistenceException.class, () -> orm.writeSet().insert(List.of(badge)));
assertTrue(exception.getMessage().contains("not insertable"));
}
diff --git a/storm-core/src/test/resources/data.sql b/storm-core/src/test/resources/data.sql
index f406076c4..197c6a07d 100644
--- a/storm-core/src/test/resources/data.sql
+++ b/storm-core/src/test/resources/data.sql
@@ -6,6 +6,7 @@ drop table if exists pet_extension CASCADE;
drop table if exists pet_type CASCADE;
drop table if exists specialty CASCADE;
drop table if exists vet CASCADE;
+drop table if exists vet_badge CASCADE;
drop table if exists vet_specialty CASCADE;
drop table if exists visit CASCADE;
@@ -15,6 +16,7 @@ create table pet (id integer auto_increment, name varchar(255), birth_date date,
create table pet_type (id integer, name varchar(255), primary key (id));
create table specialty (id integer auto_increment, name varchar(255), primary key (id));
create table vet (id integer auto_increment, first_name varchar(255), last_name varchar(255), primary key (id));
+create table vet_badge (id integer auto_increment, label varchar(255), vet_id integer, primary key (id));
create table vet_specialty (vet_id integer, specialty_id integer not null, primary key (vet_id, specialty_id));
create table visit (id integer auto_increment, visit_date date, description varchar(255), vet_id integer null, specialty_id integer null, pet_id integer not null, "timestamp" timestamp default CURRENT_TIMESTAMP, primary key (id));
create table pet_extension (pet_id integer not null, notes varchar(255), primary key (pet_id));
@@ -22,6 +24,7 @@ alter table owner add constraint owner_city_fk foreign key (city_id) references
alter table pet_extension add constraint pet_extension_pet_fk foreign key (pet_id) references pet (id);
alter table pet add constraint pet_owner_fk foreign key (owner_id) references owner (id);
alter table pet add constraint pet_pet_type_fk foreign key (type_id) references pet_type (id);
+alter table vet_badge add constraint vet_badge_vet_fk foreign key (vet_id) references vet (id);
alter table vet_specialty add constraint vet_specialty_specialty_fk foreign key (specialty_id) references specialty (id);
alter table vet_specialty add constraint vet_specialty_vet_fk foreign key (vet_id) references vet (id);
alter table visit add constraint visit_pet_fk foreign key (pet_id) references pet (id);
diff --git a/storm-foundation/src/main/java/st/orm/WriteSet.java b/storm-foundation/src/main/java/st/orm/WriteSet.java
index 69a9b0d8d..8defc4a0f 100644
--- a/storm-foundation/src/main/java/st/orm/WriteSet.java
+++ b/storm-foundation/src/main/java/st/orm/WriteSet.java
@@ -42,7 +42,10 @@
* Key propagation. Generated primary keys propagate within the set by instance
* identity : a child links to its new parent by holding the same instance, either directly in the foreign
* key field or wrapped in a {@code Ref}. The same unsaved instance describes one prospective row; two
- * structurally equal but distinct unsaved instances describe two rows.
+ * structurally equal but distinct unsaved instances describe two rows. When a foreign key field is
+ * non-insertable because its column value is carried by a component of the primary key (the junction table
+ * pattern, where the key columns live inside a composite primary key), the generated key is written into the
+ * carrying key component instead.
* Batching. Execution is grouped into one batch operation per entity type per dependency
* level (large batches are split by the configured batch size). The number of batches follows the dependency
* shape of the data: the Owner ← Pet ← Visit example below needs three, and a self-referencing type
@@ -71,9 +74,10 @@
*
* Unsaved references that cannot join the insertion closure fail fast with a descriptive exception before
* anything is written: an id-only {@code Ref} carrying a default id, an unsaved entity behind a non-insertable
- * foreign key component, an unsaved entity encountered by {@link #update(Iterable)} or {@link #remove(Iterable)},
- * and dependency cycles that cannot be executed by the dependency-ordering strategy (the write set does not break
- * cycles using nullable intermediate values, deferred constraints or follow-up updates).
+ * foreign key component whose column value is not carried by an insertable primary key component, an unsaved
+ * entity encountered by {@link #update(Iterable)} or {@link #remove(Iterable)}, and dependency cycles that cannot
+ * 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 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
From a8df7e7138649bb8df466d38ec786ca4dea48c98 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort
Date: Wed, 15 Jul 2026 01:34:33 +0200
Subject: [PATCH 3/3] refactor: make WriteSetImpl.TypeInfo a static nested
class
Pass the repository lookup explicitly to the constructor instead of
capturing WriteSetImpl.this, and make collectFkEdges static; it uses
no instance state.
---
.../java/st/orm/core/repository/impl/WriteSetImpl.java | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
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 2289fa71b..ac83f024b 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
@@ -628,7 +628,7 @@ private List> fetch(@Nonnull Execution execution) {
private record FkEdge(int[] path, String name, String fieldPath, boolean ref, Class extends Data> targetType,
boolean insertable, @Nullable int[] keyPath) {}
- private final class TypeInfo {
+ private static final class TypeInfo {
final Model, ?> model;
@SuppressWarnings("rawtypes")
final EntityRepository repository;
@@ -637,7 +637,7 @@ private final class TypeInfo {
final List fkEdges;
@SuppressWarnings("unchecked")
- TypeInfo(@Nonnull Class> type) {
+ TypeInfo(@Nonnull RepositoryLookup lookup, @Nonnull Class> type) {
this.repository = lookup.entity((Class>) type);
this.model = repository.model();
this.autoGeneratedPrimaryKey = model.declaredColumns().stream()
@@ -784,7 +784,7 @@ private static Class> wrap(@Nonnull Class> type) {
}
private TypeInfo typeInfo(@Nonnull Class> type) {
- return typeInfoCache.computeIfAbsent(type, TypeInfo::new);
+ return typeInfoCache.computeIfAbsent(type, key -> new TypeInfo(lookup, key));
}
/**
@@ -792,7 +792,7 @@ private TypeInfo typeInfo(@Nonnull Class> type) {
* flag tracks inherited {@code @Persist} semantics: an inline component marked non-insertable propagates to its
* children.
*/
- private void collectFkEdges(@Nonnull RecordType recordType,
+ private static void collectFkEdges(@Nonnull RecordType recordType,
@Nonnull List path,
@Nonnull List nameParts,
boolean insertable,