Skip to content

feat: dependency-aware write sets for mixed-type entity graphs#270

Merged
zantvoort merged 4 commits into
mainfrom
feat/write-sets
Jul 14, 2026
Merged

feat: dependency-aware write sets for mixed-type entity graphs#270
zantvoort merged 4 commits into
mainfrom
feat/write-sets

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Problem

Inserting an object graph that spans several tables requires hand-rolled choreography: insert parents, correlate generated keys back by position, rebuild children against those keys, repeat per level. The same ordering problem exists in reverse for deletes. Storm's fail-fast validation correctly rejects unsaved references, but the only remedy it could suggest was manual ordering.

Design

A write set applies one write operation to a heterogeneous collection of entities, obtained via orm.writeSet() (also available on repositories, delegating to the template):

orm.writeSet().insert(entities);   // mixed entity types
orm.writeSet().update(entities);
orm.writeSet().upsert(entities);
orm.writeSet().remove(entities);
// plus insertAndFetch / updateAndFetch / upsertAndFetch, and typed single-root variants

Contract:

  1. Insert and upsert write the explicit members plus their insertion closure: unsaved entities transitively reachable through insertable, entity-valued foreign key fields (including fields inside inline components and entity-wrapped refs). Referenced entities that already carry a key are never discovered; they only bind as foreign key values.
  2. Update and remove write exactly the explicit members. Referenced entities are never written implicitly.
  3. Execution order follows the foreign key dependencies: parents first for insert/upsert (covering both unsaved dependencies and keyed member-to-member references), children first for remove (correlated by type and primary key).
  4. Generated keys propagate by instance identity via record reconstruction; the same unsaved instance describes one row, equal-but-distinct instances describe two.
  5. One batch operation per entity type per dependency level; generated keys are only fetched when a dependent or the caller consumes them, which keeps leaf writes compatible with dialects that cannot return keys for every generation strategy (e.g. H2 with sequences).

Unsaved is the deterministic local test Storm already uses: default primary key with an auto-generation strategy. No session, no cache involvement, no annotation-driven cascade; per-row semantics (callbacks, dirty checking, native upsert, joined inheritance) are exactly those of the per-repository operations, which the executor delegates to.

Fail-fast before any statement executes: unorderable dependency cycles, id-only refs with default ids, unsaved members in update/remove, and unsaved entities behind non-insertable foreign key components.

Implementation notes

  • Shared st.orm.WriteSet interface in storm-foundation; executor WriteSetImpl in storm-core; writeSet() on RepositoryLookup/Repository in core, mirrored in storm-java21 and storm-kotlin.
  • Ref.of(entity) no longer requires a persisted id, so entity-wrapped refs can carry unsaved instances as graph edges. Key propagation rebuilds ref components by wrapping the persisted instance, preserving concrete subtypes.
  • The unsaved-foreign-key error message now points to writeSet().
  • Kotlin adds a scoped writeSet { } block (eager; groups calls, never defers or reorders).

Docs and AI assets

  • New docs/write-sets.md (sidebar: Operations), README docs table row, comparison table updates (Cascade persist cell + Graph persistence aspect row), llms-full.txt regenerated (page added to the generation script's list), and Write Sets sections in the storm-repository-{java,kotlin} skills served at orm.st/skills.

Tests

24 tests across four suites: WriteSetIntegrationTest (storm-core, 18: closure, shared parents, inline-component and wrapped-ref edges, keyed-member ordering, input-order fetch, equal-but-distinct instances, update/remove isolation, repository access incl. proxy dispatch, single-root variants, fail-fast paths), WriteSetLevelingTest (cycle detection), WriteSetTest.kt (storm-kotlin, data classes, block DSL), H2WriteSetTest (storm-h2, real MERGE upserts, sequence keys, upsert ordering precedence). Full reactor green.

Known limitations

  • Remove ordering correlates members by the declared foreign key component type; polymorphic members whose concrete class differs from the declared sealed type do not receive ordering edges.
  • Whether upsert can create a row for a member carrying a preset key follows the dialect's per-repository upsert behavior (core default routes keyed members to the update branch; dialect MERGE implementations may create the row). Documented, not unified here.
  • Entity callbacks run inside the per-type operations, after discovery and planning; callbacks that alter foreign key fields do not re-plan the graph (documented as a non-goal).

Fixes #269

Add WriteSet, obtained via writeSet() on ORMTemplate and repositories:
insert/update/upsert/remove plus AndFetch variants over collections that
may span multiple entity types. Insert and upsert extend the explicit
members with their insertion closure (unsaved entities transitively
reachable through insertable foreign key fields, including inline
components and entity-wrapped refs). Execution is ordered by foreign key
dependencies, batched per type per dependency level, and generated keys
propagate to dependents by instance identity via record reconstruction.
Remove deletes children before parents, correlating members by type and
primary key. Generated keys are only fetched when a dependent or the
caller consumes them.

Ref.of(entity) now accepts unsaved entities so refs can carry graph
edges. The unsaved-foreign-key error message points to writeSet().
Kotlin adds a scoped writeSet { } block; single-root typed variants are
default methods on the shared interface.

Fixes #269
@zantvoort zantvoort added this to the 1.13.0 milestone Jul 14, 2026
Comment thread storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java Outdated
…on tables

A non-insertable FK component whose column value is carried by an
insertable component of a composite primary key (the junction table
pattern) can now join the insertion closure: the generated key is
written into the carrying key component instead of failing fast.
Pass the repository lookup explicitly to the constructor instead of
capturing WriteSetImpl.this, and make collectFkEdges static; it uses
no instance state.
# Conflicts:
#	website/static/llms-full.txt
@zantvoort
zantvoort merged commit b8dcbd4 into main Jul 14, 2026
6 checks passed
@zantvoort
zantvoort deleted the feat/write-sets branch July 14, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dependency-aware write sets: mixed-type insert/update/delete/upsert with generated-key propagation

1 participant