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:

* *