Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions docs/write-sets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
```

</TabItem>
Expand All @@ -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);
```

</TabItem>
Expand Down Expand Up @@ -98,7 +98,7 @@ Key propagation correlates by instance, not by `equals`. A `copy()` of an unsave
<TabItem value="kotlin" label="Kotlin" default>

```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
Expand All @@ -108,7 +108,7 @@ val fetchedVisit: Visit = orm.writeSet().insertAndFetch(visit) // single root,
<TabItem value="java" label="Java">

```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
Expand All @@ -126,15 +126,15 @@ 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
```

</TabItem>
<TabItem value="java" label="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
orm.writeSet().insert(pet); // owner is inserted first, the ref binds the generated key
```

</TabItem>
Expand Down Expand Up @@ -187,7 +187,7 @@ val user = User(name = "Alice") // unsaved
val role = orm.entity<Role>().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
```

</TabItem>
Expand All @@ -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
```

</TabItem>
Expand All @@ -218,14 +218,14 @@ The remaining actions follow the same contract, each with the ordering that suit
<TabItem value="kotlin" label="Kotlin" default>

```kotlin
orm.writeSet().remove(listOf(owner, pet, visit)) // executed as: visit, pet, owner
orm.writeSet().remove(owner, pet, visit) // executed as: visit, pet, owner
```

</TabItem>
<TabItem value="java" label="Java">

```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
```

</TabItem>
Expand Down
34 changes: 34 additions & 0 deletions storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

}
91 changes: 87 additions & 4 deletions storm-foundation/src/main/java/st/orm/WriteSet.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
* and {@link #upsert(Iterable)} extend the <em>explicit members</em> (the entities supplied by the caller) with
* <em>discovered members</em>: unsaved entities transitively reachable through insertable, entity-valued foreign key
* fields (the <em>insertion closure</em>). {@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:</p>
* 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:</p>
*
* <ul>
* <li><strong>Insertion closure.</strong> A record whose foreign key field holds an unsaved entity is a value that
Expand Down Expand Up @@ -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
* }</pre>
*
Expand Down Expand Up @@ -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.
Expand All @@ -130,6 +142,19 @@ public interface WriteSet {
@Nonnull
List<Entity<?>> 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<Entity<?>> insertAndFetch(@Nonnull Entity<?>... entities) {
return insertAndFetch(List.of(entities));
}

/**
* Updates the given entities, grouped by type.
*
Expand All @@ -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.
Expand All @@ -160,6 +195,19 @@ public interface WriteSet {
@Nonnull
List<Entity<?>> 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<Entity<?>> updateAndFetch(@Nonnull Entity<?>... entities) {
return updateAndFetch(List.of(entities));
}

/**
* Upserts the explicit members and inserts their insertion closure, in dependency order.
*
Expand All @@ -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.
Expand All @@ -188,6 +248,19 @@ public interface WriteSet {
@Nonnull
List<Entity<?>> 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<Entity<?>> upsertAndFetch(@Nonnull Entity<?>... entities) {
return upsertAndFetch(List.of(entities));
}

/**
* Removes the given entities, children before parents.
*
Expand All @@ -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.
//
Expand Down
15 changes: 15 additions & 0 deletions storm-kotlin/src/test/kotlin/st/orm/template/WriteSetTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,19 @@ open class WriteSetTest(
orm.entity<Pet, _>().findAll().none { it.name == "Doomed" } shouldBe true
orm.entity<Visit, _>().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<Owner, _>().findAll().none { it.firstName == "Vera" } shouldBe true
orm.entity<Pet, _>().findAll().none { it.name == "VarargPet" } shouldBe true
orm.entity<Visit, _>().findAll().none { it.description == "Vararg visit" } shouldBe true
}
}
Loading