From 2635835a094ce327b8d72c97a38d2b08933d6f54 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort
Date: Wed, 15 Jul 2026 09:27:55 +0200
Subject: [PATCH] feat: vararg overloads for the write set actions
All seven actions accept entities as varargs next to the canonical
Iterable forms: insert, insertAndFetch, update, updateAndFetch, upsert,
upsertAndFetch and remove. The overloads are default methods delegating
to the Iterable forms; Entity> is reifiable, so they are warning-free
without @SafeVarargs. Single-entity and typed single-root overloads
keep winning one-argument resolution in both Java and Kotlin, and an
empty call is a no-op.
The write-sets reference now shows the vararg shape where entities are
enumerated inline, matching the comparison pages shipped in #272.
Fixes #273
---
docs/write-sets.md | 22 ++---
.../st/orm/core/WriteSetIntegrationTest.java | 34 +++++++
.../src/main/java/st/orm/WriteSet.java | 91 ++++++++++++++++++-
.../kotlin/st/orm/template/WriteSetTest.kt | 15 +++
4 files changed, 147 insertions(+), 15 deletions(-)
diff --git a/docs/write-sets.md b/docs/write-sets.md
index 92ad15e7b..dfbcfb1f8 100644
--- a/docs/write-sets.md
+++ b/docs/write-sets.md
@@ -5,7 +5,7 @@ import TabItem from '@theme/TabItem';
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.
+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. Every action accepts the entities as varargs or as any `Iterable`.
Write sets are not a cascade in the JPA sense. There are no mapping annotations, no persistence context and no session-wide cascade: all writes derive from the entities supplied to the call and, for insert and upsert, their insertion closure. The per-row semantics of every action are identical to the corresponding repository operation, including entity callbacks and dirty checking. Coming from JPA? [JPA Cascades vs Write Sets](jpa-cascades-vs-write-sets.md) compares the two models side by side.
@@ -52,7 +52,7 @@ val wolfie = Pet(name = "Wolfie", birthDate = birthDate, type = dog, owner = own
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))
+orm.writeSet().insert(wolfie, rex, visit)
```
@@ -64,7 +64,7 @@ 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));
+orm.writeSet().insert(wolfie, rex, visit);
```
@@ -98,7 +98,7 @@ Key propagation correlates by instance, not by `equals`. A `copy()` of an unsave
```kotlin
-val inserted = orm.writeSet().insertAndFetch(listOf(wolfie, visit))
+val inserted = orm.writeSet().insertAndFetch(wolfie, visit)
val fetchedPet = inserted[0] as Pet // fetchedPet.owner carries the generated key
val fetchedVisit: Visit = orm.writeSet().insertAndFetch(visit) // single root, typed
@@ -108,7 +108,7 @@ val fetchedVisit: Visit = orm.writeSet().insertAndFetch(visit) // single root,
```java
-var inserted = orm.writeSet().insertAndFetch(List.of(wolfie, visit));
+var inserted = orm.writeSet().insertAndFetch(wolfie, visit);
var fetchedPet = (Pet) inserted.get(0); // fetchedPet.owner() carries the generated key
Visit fetchedVisit = orm.writeSet().insertAndFetch(visit); // single root, typed
@@ -126,7 +126,7 @@ Foreign key fields typed as `Ref` participate through entity-wrapped refs, which
```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
+orm.writeSet().insert(pet) // owner is inserted first, the ref binds the generated key
```
@@ -134,7 +134,7 @@ orm.writeSet().insert(listOf(pet)) // owner is inserted first, the ref bind
```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
+orm.writeSet().insert(pet); // owner is inserted first, the ref binds the generated key
```
@@ -187,7 +187,7 @@ 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
+orm.writeSet().insert(userRole) // user is inserted first; its key lands in userRolePk.userId
```
@@ -198,7 +198,7 @@ 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
+orm.writeSet().insert(userRole); // user is inserted first; its key lands in userRolePk.userId
```
@@ -218,14 +218,14 @@ The remaining actions follow the same contract, each with the ordering that suit
```kotlin
-orm.writeSet().remove(listOf(owner, pet, visit)) // executed as: visit, pet, owner
+orm.writeSet().remove(owner, pet, visit) // executed as: visit, pet, owner
```
```java
-orm.writeSet().remove(List.of(owner, pet, visit)); // executed as: visit, pet, owner
+orm.writeSet().remove(owner, pet, visit); // executed as: visit, pet, owner
```
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 81304dc16..0c7988f3e 100644
--- a/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
+++ b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
@@ -466,4 +466,38 @@ public void testRemoveUnsavedFails() {
assertTrue(exception.getMessage().contains("unsaved"));
}
+ @Test
+ public void testVarargActionsAcceptEnumeratedEntities() {
+ var orm = orm();
+ var owner = newOwner("Vararg", "Actions");
+ var pet = Pet.builder().name("VarargPet").birthDate(LocalDate.of(2024, 4, 4)).type(dogType()).owner(owner).build();
+ var visit = new Visit(LocalDate.of(2026, 5, 5), "Vararg visit", pet);
+ // Multi-argument calls resolve to the vararg overloads; single-argument calls keep resolving to the
+ // typed single-root variants (see testSingleRootConvenienceVariants).
+ var inserted = orm.writeSet().insertAndFetch(pet, visit);
+ assertEquals(2, inserted.size());
+ var insertedPet = (Pet) inserted.get(0);
+ var insertedVisit = (Visit) inserted.get(1);
+ assertNotEquals(0, insertedPet.owner().id());
+ var renamedPet = insertedPet.toBuilder().name("VarargRenamed").build();
+ orm.writeSet().update(renamedPet, insertedVisit);
+ assertEquals("VarargRenamed", orm.entity(Pet.class).getById(insertedPet.id()).name());
+ orm.writeSet().remove(insertedPet.owner(), renamedPet, insertedVisit);
+ assertTrue(orm.entity(Owner.class).findById(insertedPet.owner().id()).isEmpty());
+ assertTrue(orm.entity(Pet.class).findById(insertedPet.id()).isEmpty());
+ assertTrue(orm.entity(Visit.class).findById(insertedVisit.id()).isEmpty());
+ }
+
+ @Test
+ public void testEmptyVarargCallsAreNoOps() {
+ var orm = orm();
+ orm.writeSet().insert();
+ orm.writeSet().update();
+ orm.writeSet().upsert();
+ orm.writeSet().remove();
+ assertTrue(orm.writeSet().insertAndFetch().isEmpty());
+ assertTrue(orm.writeSet().updateAndFetch().isEmpty());
+ assertTrue(orm.writeSet().upsertAndFetch().isEmpty());
+ }
+
}
diff --git a/storm-foundation/src/main/java/st/orm/WriteSet.java b/storm-foundation/src/main/java/st/orm/WriteSet.java
index 9ef26d6af..e0523803d 100644
--- a/storm-foundation/src/main/java/st/orm/WriteSet.java
+++ b/storm-foundation/src/main/java/st/orm/WriteSet.java
@@ -25,9 +25,9 @@
* 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 action are identical to the corresponding
- * {@code EntityRepository} operation; the write set adds partitioning by type, dependency ordering and generated-key
- * propagation:
+ * explicit members only. Each action accepts the entities as any {@code Iterable} or as varargs. The per-row
+ * semantics of each action are identical to the corresponding {@code EntityRepository} operation; the write set adds
+ * partitioning by type, dependency ordering and generated-key propagation:
*
*
* - Insertion closure. A record whose foreign key field holds an unsaved entity is a value that
@@ -68,7 +68,7 @@
* 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:
+ * orm.writeSet().insert(wolfie, rex, visit); // owner joins via the insertion closure:
* // one Owner, one Pet and one Visit batch
* }
*
@@ -115,6 +115,18 @@ public interface WriteSet {
*/
void insert(@Nonnull Iterable extends Entity>> entities);
+ /**
+ * Inserts the given entities and their insertion closure; see {@link #insert(Iterable)}. An empty call is a
+ * no-op.
+ *
+ * @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.
+ */
+ default void insert(@Nonnull Entity>... entities) {
+ insert(List.of(entities));
+ }
+
/**
* Inserts like {@link #insert(Iterable)} and returns the explicit members as they exist in the database after
* the insert, in input order.
@@ -130,6 +142,19 @@ public interface WriteSet {
@Nonnull
List> insertAndFetch(@Nonnull Iterable extends Entity>> entities);
+ /**
+ * Inserts like {@link #insertAndFetch(Iterable)} and returns the explicit members as they exist in the database
+ * after the insert, in input order; an empty call is a no-op and returns an empty list.
+ *
+ * @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
+ default List> insertAndFetch(@Nonnull Entity>... entities) {
+ return insertAndFetch(List.of(entities));
+ }
+
/**
* Updates the given entities, grouped by type.
*
@@ -149,6 +174,16 @@ public interface WriteSet {
*/
void update(@Nonnull Iterable extends Entity>> entities);
+ /**
+ * Updates the given entities; see {@link #update(Iterable)}. An empty call is a no-op.
+ *
+ * @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.
+ */
+ default void update(@Nonnull Entity>... entities) {
+ update(List.of(entities));
+ }
+
/**
* Updates like {@link #update(Iterable)} and returns the passed entities as they exist in the database after the
* update, in input order.
@@ -160,6 +195,19 @@ public interface WriteSet {
@Nonnull
List> updateAndFetch(@Nonnull Iterable extends Entity>> entities);
+ /**
+ * Updates like {@link #updateAndFetch(Iterable)} and returns the passed entities as they exist in the database
+ * after the update, in input order; an empty call is a no-op and returns an empty list.
+ *
+ * @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
+ default List> updateAndFetch(@Nonnull Entity>... entities) {
+ return updateAndFetch(List.of(entities));
+ }
+
/**
* Upserts the explicit members and inserts their insertion closure, in dependency order.
*
@@ -177,6 +225,18 @@ public interface WriteSet {
*/
void upsert(@Nonnull Iterable extends Entity>> entities);
+ /**
+ * Upserts the given entities and inserts their insertion closure; see {@link #upsert(Iterable)}. An empty call
+ * is a no-op.
+ *
+ * @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.
+ */
+ default void upsert(@Nonnull Entity>... entities) {
+ upsert(List.of(entities));
+ }
+
/**
* Upserts like {@link #upsert(Iterable)} and returns the passed entities as they exist in the database after the
* upsert, in input order.
@@ -188,6 +248,19 @@ public interface WriteSet {
@Nonnull
List> upsertAndFetch(@Nonnull Iterable extends Entity>> entities);
+ /**
+ * Upserts like {@link #upsertAndFetch(Iterable)} and returns the passed entities as they exist in the database
+ * after the upsert, in input order; an empty call is a no-op and returns an empty list.
+ *
+ * @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
+ default List> upsertAndFetch(@Nonnull Entity>... entities) {
+ return upsertAndFetch(List.of(entities));
+ }
+
/**
* Removes the given entities, children before parents.
*
@@ -201,6 +274,16 @@ public interface WriteSet {
*/
void remove(@Nonnull Iterable extends Entity>> entities);
+ /**
+ * Removes the given entities, children before parents; see {@link #remove(Iterable)}. An empty call is a no-op.
+ *
+ * @param entities the entities to remove; may span multiple entity types.
+ * @throws PersistenceException if a passed entity is unsaved, or if the removal fails.
+ */
+ default void remove(@Nonnull Entity>... entities) {
+ remove(List.of(entities));
+ }
+
//
// Single-root convenience variants.
//
diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt
index 5b1465c3e..7f79b5010 100644
--- a/storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt
@@ -111,4 +111,19 @@ open class WriteSetTest(
orm.entity().findAll().none { it.name == "Doomed" } shouldBe true
orm.entity().findAll().none { it.description == "Last visit" } shouldBe true
}
+
+ @Test
+ fun `actions should accept entities as varargs`() {
+ val owner = newOwner("Vera")
+ val pet = Pet(name = "VarargPet", birthDate = LocalDate.of(2024, 4, 4), type = dog, owner = owner)
+ val visit = Visit(visitDate = LocalDate.of(2026, 7, 15), description = "Vararg visit", pet = pet, timestamp = Instant.now())
+ val inserted = orm.writeSet().insertAndFetch(pet, visit)
+ inserted shouldHaveSize 2
+ val insertedPet = inserted[0] as Pet
+ val insertedVisit = inserted[1] as Visit
+ orm.writeSet().remove(insertedPet.owner.shouldNotBeNull(), insertedPet, insertedVisit)
+ orm.entity().findAll().none { it.firstName == "Vera" } shouldBe true
+ orm.entity().findAll().none { it.name == "VarargPet" } shouldBe true
+ orm.entity().findAll().none { it.description == "Vararg visit" } shouldBe true
+ }
}