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 dbd9f12ee..6bfbb1072 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 | @@ -82,6 +84,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/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 new file mode 100644 index 000000000..479e6b335 --- /dev/null +++ b/docs/write-sets.md @@ -0,0 +1,253 @@ +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. + +## 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: + +- `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 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. + +## 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..ac83f024b --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java @@ -0,0 +1,859 @@ +/* + * 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.invoke.MethodType; +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. 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.

+ * + * @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> entities) { + executeOrdered(entities, Verb.INSERT, false); + } + + @Override + @Nonnull + public List> insertAndFetch(@Nonnull Iterable> entities) { + Execution execution = executeOrdered(entities, Verb.INSERT, true); + return fetch(execution); + } + + @Override + public void upsert(@Nonnull Iterable> entities) { + executeOrdered(entities, Verb.UPSERT, false); + } + + @Override + @Nonnull + public List> upsertAndFetch(@Nonnull Iterable> entities) { + Execution execution = executeOrdered(entities, Verb.UPSERT, true); + return fetch(execution); + } + + @Override + public void update(@Nonnull Iterable> entities) { + executeUpdate(entities); + } + + @Override + @Nonnull + public List> updateAndFetch(@Nonnull Iterable> entities) { + Execution execution = executeUpdate(entities); + return fetch(execution); + } + + @Override + public void remove(@Nonnull Iterable> 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> 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 && edge.keyPath == null) { + throw new PersistenceException(("Foreign key component '%s.%s' is not insertable but references " + + "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())); + } + 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. 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 && 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); + } + } + } + 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); + 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. + // + + private Execution executeUpdate(@Nonnull Iterable> 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> 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 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. + * 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 targetType, + boolean insertable, @Nullable int[] keyPath) {} + + private static final class TypeInfo { + final Model model; + @SuppressWarnings("rawtypes") + final EntityRepository repository; + final boolean autoGeneratedPrimaryKey; + final int primaryKeyIndex; + final List fkEdges; + + @SuppressWarnings("unchecked") + TypeInfo(@Nonnull RepositoryLookup lookup, @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<>(), 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) { + return typeInfoCache.computeIfAbsent(type, key -> new TypeInfo(lookup, key)); + } + + /** + * 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 static void collectFkEdges(@Nonnull RecordType recordType, + @Nonnull List path, + @Nonnull List nameParts, + 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); + 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 targetType = refTargetType(field); + if (Entity.class.isAssignableFrom(targetType)) { + 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(), fieldPath, false, + (Class) field.type(), fieldInsertable, null)); + } + nameParts.remove(nameParts.size() - 1); + 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); + nameParts.add(field.name()); + collectFkEdges(nested.get(), path, nameParts, fieldInsertable, edges); + nameParts.remove(nameParts.size() - 1); + 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 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) 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/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 new file mode 100644 index 000000000..81304dc16 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/WriteSetIntegrationTest.java @@ -0,0 +1,469 @@ +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 jakarta.annotation.Nonnull; +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.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; +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 testInsertJunctionPropagatesGeneratedKeyIntoCompositePk() { + var orm = orm(); + var vet = Vet.builder().firstName("New").lastName("JunctionVet").build(); + var vetSpecialty = new VetSpecialty( + new VetSpecialtyPK(0, 1), + vet, + Specialty.builder().id(1).name("radiology").build()); + 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")); + } + + 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-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/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..8defc4a0f --- /dev/null +++ b/storm-foundation/src/main/java/st/orm/WriteSet.java @@ -0,0 +1,278 @@ +/* + * 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. 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 + * 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 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 + * 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> 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> 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> 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> 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> 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> 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> 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 9f9f4d899..443d67e48 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -46,6 +46,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 180f4d4e4..872de575b 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -18,7 +18,7 @@ > GitHub: https://github.com/storm-orm/storm-framework > License: Apache 2.0 -# Generated: 2026-07-14T20:31:03Z +# Generated: 2026-07-14T23:35:52Z ======================================== ## Source: index.md @@ -2661,7 +2661,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: @@ -12150,6 +12150,218 @@ 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. + +## 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] + +```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] + +```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] + +```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] + +```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: + +- `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 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. + +## 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 ======================================== @@ -17562,7 +17774,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. @@ -17573,6 +17785,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 | @@ -17626,6 +17840,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/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: