Saving Data
A save writes what the object holds. It does not depend on whether the object was loaded first, or on anything the manager remembers, because the manager remembers nothing.
import org.drivine.manager.Replaceimport org.drivine.manager.RemovedTargetsimport org.drivine.manager.nodeRefimport org.drivine.manager.update // the reified update<T>(id) { }
graphObjectManager.save(view) // every non-null field; relationships are added, never removedgraphObjectManager.save(view, Replace(IssueView::assignedTo)) // this field's list is the whole listgraphObjectManager.save(view, Replace.all()) // every relationship field's list is the whole listgraphObjectManager.save(chunk, except = setOf(Chunk::embedding)) // write everything but thesegraphObjectManager.save(person, only = setOf(Person::name)) // write just thesegraphObjectManager.update<Person>(id) { it.copy(name = "Ada") } // load, change, save what differsgraphObjectManager.create(person) // make it only if it is not there; null when it isgraphObjectManager.edges.unrelate(nodeRef<Issue>(a), nodeRef<Person>(b), "ASSIGNED_TO")A save is one Cypher statement: the root, the relationships it drops, each related node and the relationship to it. It is applied whole or not at all, with or without a transaction, on Neo4j, FalkorDB and Memgraph. Under NullPolicy.CLEAR, a root with a @PropertyBag or an open @NodeLabels field is read first, for the keys and labels it holds: one more statement, which writes nothing.
only and except name fields of the object, and for a view fields of its root. A view’s relationships are written whatever they name. Give one or the other: both together are refused, and so is a property of a class other than the one saved.
What save returns
Section titled “What save returns”save returns the saved object. On a type with a @NodeStamp field, use the returned object from then on: it carries the stamps the save left, on the root and on each related node that has one.
| The class | What comes back |
|---|---|
| declares no stamp field, on the root or on any related node | the object you passed |
| a Kotlin data class | a copy, as its copy would make |
| has fields that can be set, such as a Java object with setters | the object you passed, given its stamps in place |
| is immutable and not a data class, such as a Java record | a copy the object mapper rebuilds |
A class of the last kind that the object mapper cannot rebuild is refused with IllegalArgumentException before anything is written.
Relationships
Section titled “Relationships”A save adds the relationships the object holds and removes none. To remove, name the field in Replace: the field’s list is then the whole list.
- A field removes only what it loads: relationships of its type and direction, to nodes with its target’s labels. Two fields can share a relationship type, and a relationship that another field of the view holds, of the same type and direction, is that field’s to keep.
Replace(field, removedTargets = DELETE_UNREFERENCED)also deletes a removed target that nothing else refers to the way the field did. For an outgoing field that is a target no relationship points at; for an incoming field, one that points at nothing else; for an undirected field, one with no relationship left. The target’s other relationships go with it. The root is never deleted, though a relationship from it to itself is removed. A node the object still holds, in another of its fields or in a view nested in one, is never deleted: a target moved from one list to another is kept. Anything more is a custom view or Cypher.Replace.all()covers every relationship field of the view itself. It is refused for an object that carries no stamp, because the lists of an object built from scratch are its defaults and not what the store holds. A view whose root declares no@NodeStampfield therefore names its fields.- The lists of a view nested in the one saved only add: to trim one, use
update, or save the nested view withReplace. - A list to replace that is null is refused, whether it is named or covered by
Replace.all(). An empty list removes every relationship of the field. - A single-valued relationship field that is null, and is named in
Replaceor covered byReplace.all(), removes the relationship: for one node, null is how “none” is said. Replacetrusts that the list came from a load. A list cut short by a custom query is taken as the whole list.Replaceis refused, on a root that carries a stamp, if any relationship of the root was added or removed, or had its properties changed, since the object was loaded. A save that only adds is not: two writers who loaded the same view can each add to it. See when a save is refused.- An
UNDIRECTEDfield is satisfied by a relationship stored in either direction. One is made, from the root, only when there is none. - A node a field holds more than once is written once, as the last of them says, and joined once.
DELETE_UNREFERENCEDnever deletes a node the object still holds in another of its fields: a target moved from one list to another is kept.- A null property of a relationship fragment is left alone on the relationship, as a null field of a related node is.
updateclears one the change set to null. - Where the store holds several relationships of one type between the same two nodes, a save writes its properties to each of them.
- A read-only field is never written: see
@ReadOnly. edges.unrelate(from, to, type)removes the relationships of a type from one node to another, andedges.unrelateAll(from, type, direction)every one of a type. Neither deletes a node or touches a node’s own properties.
The statement’s size. The related nodes of a field are the rows of one UNWIND, so the statement’s text does not grow with a list and an engine plans it once. Three kinds of related node still have a part of the statement each: a related view, a fragment with a @PropertyBag or a @NodeLabels field, and on FalkorDB a fragment with a @VectorIndex field. A long list of those makes a long statement. Index the id of each node type you save, as for any MERGE.
Load, Change and Save (update)
Section titled “Load, Change and Save (update)”update loads the object, applies your change, and writes only what the change altered: the fields that differ, a field set to null, and for a view the relationships it added or dropped and the related nodes it altered. Of a related node that was loaded it writes the fields that differ and clears one set to null, and leaves the rest, so another writer’s change to a field you did not touch stands. A related node the change added is written whole. A relationship another writer added in the meantime is kept, and one another writer removed stays removed. Your change may return a copy, or change the object it is given and return that.
- If the node changed between the load and the save,
updateloads it again and re-applies your change: three attempts in all by default, and thenStaleObjectExceptionis thrown. - That needs a
@NodeStampfield. Without one a change by another writer is not noticed. - The id is a
Stringor aUUID. updatereturns null when there is no such node, and when the node is deleted between the load and the save. It writes to the node it loaded and never makes one.- A
StaleObjectExceptionthat your change throws itself, from a save of its own, is thrown on and not tried again. - A change that gives the object another id, or returns an object of another class, is refused.
- The load and the save are two statements. The save is one, and is refused if the node changed in between. A change to an open
@NodeLabelsfield adds a read of the labels the node records as its own.
Create If Absent (create)
Section titled “Create If Absent (create)”create makes the node only if no node has its id, in one statement. If one does, it writes nothing and returns null. With update, that is a save of an aggregate that loses no writer’s data:
val stored = graphObjectManager.create(proposition) ?: graphObjectManager.update<Proposition>(proposition.id) { it.merge(proposition) }A save of an object built from scratch cannot do this. Two writers that each load null and each save write the same node, and the second overwrites the first.
- It returns the object carrying the whole stamp the save left, as
savedoes, or null when a node has the id. It is then forupdateto merge into what is there. - When the node is there, nothing is written: not the root, not a related node, not a relationship, not the stamp.
- A view is made as
savemakes it: its root, each related node and the relationship to it. A related node is written unchecked, assavewrites it, so one that was already there takes the object’s fields. - An object that carries a stamp is refused, with
IllegalArgumentExceptionand before anything is written: a stamp comes from a node that was loaded, andcreatemakes one that is not there. Save it, or update it. - Two writers at the same moment. Both find the node absent, as two
MERGEs do. On Neo4j, Memgraph and Oracle only a uniqueness constraint on the id keeps the second from making the node again:constraints.ensure(UniquenessConstraintSpec("Proposition", "id")). With one, the second waits for the first and finds its node (Neo4j), or is refused by the constraint (Memgraph, Oracle); either way it writes nothing and returns null. FalkorDB runs one write at a time, so there the second always finds the first’s node, constraint or not. Without a constraint on an engine that runs writes side by side, both can make a node, and the id is then on two nodes. - In a transaction.
createjoins it. On Memgraph a uniqueness constraint is checked when the transaction commits, so two transactions that each made the node fail at the commit of the second, and not at itscreate: run that transaction again, and itscreatereturns null. Where the engine refuses the statement itself and the node is then found,createreturns null, but the engine may already have marked the transaction for rollback, so anupdateafter it in the same transaction fails. Run the transaction again. - The node is found by every label of the object’s class, as a
savefinds it. AManagerlabelledWorker:Manageris found bycreate(worker), but a node labelled onlyWorkeris not found bycreate(manager). Without a constraint, a second node is made. With a uniqueness constraint on a label the two share, the store refuses it andcreatethrows. - The statement is run again when the engine turns it away because another writer was changing the node, as a save is.
createis onStatelessGraphObjectManageralone, assaveandupdateare. There is nocreateAll: make each node with its owncreate.
See docs/0.1.7-create-if-absent.md for why create returns null and how each engine makes the node only if it is absent.
Null-Write Policy (NullPolicy)
Section titled “Null-Write Policy (NullPolicy)”How a null field is treated on save is one declared, uniform contract (save, saveAll, every
engine, bagged or not) — defined purely on the object you pass:
graphObjectManager.save(chunk) // IGNORE (default): merge-patchgraphObjectManager.save(chunk, nullPolicy = NullPolicy.CLEAR) // full overwrite: nulls clearIGNORE(default) — writes only non-null fields; nulls are left untouched. A partially-loaded object never destroys stored data — including a@VectorIndexembedding (aChunkNodereconstructed without its embedding won’t wipe the stored vector). This is the safe default; no field is special.CLEAR— the object is authoritative: null fields clear the corresponding property (a full overwrite). Reach for it only with a complete object.
In a view the policy governs the root. A related node is written as under IGNORE: a null field of
it is left alone, and so are its stale @PropertyBag keys. To clear a field of a related node, use
update, or save that node itself.
saveAll(..., nullPolicy = …) behaves identically. Under
CLEAR, a @PropertyBag also drops keys absent from the current map; under IGNORE those keys are
left (merge-patch). See docs/0.0.73-null-write-policy.md.
Batch Save (saveAll)
Section titled “Batch Save (saveAll)”Persist a collection in one group of statements. Within an ambient @Transactional the statements join it; otherwise they run together in a single transaction, and a failure on any item rolls the whole call back.
On Neo4j, FalkorDB and Memgraph, a batch of fragments alone, of any number of labels, is one statement where each fragment can be written as a row of an UNWIND and there are no more than 1,000 of them: it is applied whole on any of them, FalkorDB included. On Oracle a batch of fragments is one block for each 500 of them. Each view is a statement of its own, and so is a fragment with a @PropertyBag or a @NodeLabels field, or on FalkorDB one with a @VectorIndex field: the other fragments are still one statement. In it the classes go in the order of their names and the rows of each in the order of their ids, so two batches over the same nodes take their locks in the same order. FalkorDB has no multi-statement transactions, so a batch of more than one statement is not applied whole there: in the default WARN mode it runs statement by statement and the first such batch logs a warning, and with falkorDbTransactionMode set to STRICT it is refused with NonAtomicBatchException before anything is written (inside @Transactional, STRICT refuses the transaction itself, at its first statement). PersistenceManager.atomicBatches says which a store does. See docs/0.1.7-atomic-save-all.md.
val saved = graphObjectManager.saveAll(views)val replaced = graphObjectManager.saveAll(views, Replace(PropositionView::mentions))graphObjectManager.saveAll(chunks, only = setOf(Chunk::summary, Chunk::summaryModel)) // write just these fields of eachgraphObjectManager.saveAll(chunks, except = setOf(Chunk::embedding)) // write everything but these- Fragments collapse into chunked
UNWIND … MERGEstatements (sub-linear round trips), with or without a@NodeStampfield. A view is saved by a statement of its own, assavedoes it. - Heterogeneous collections are grouped by runtime class, and the returned list preserves input order.
- A fragment with a
@PropertyBagor a@NodeLabelsfield is saved one statement each. So, on FalkorDB, is a fragment with a@VectorIndexfield, which that engine stores in a form a batch of rows cannot write. - A batch that only adds does not check stamps. An object that carries a stale stamp is written all the same, where
saveof it would be refused. The object handed back then keeps the stamp it had, so a latersaveof it is refused: it does not hold what the other writer left. - Otherwise the returned objects carry the stamps the batch left, so each can be saved again.
- A
Replaceapplies to views: a batch that holds a fragment is refused with it. Each view that carries a stamp is checked assavechecks it, and a stale one throwsStaleObjectException. On Neo4j and Memgraph the batch is then not applied. On FalkorDB the statements that ran before the refused one stay applied. - Inside a transaction of the caller’s, a refusal undoes the batch only if that transaction is rolled back, as it is when the exception leaves it. Code that catches the exception and carries on commits what the batch wrote before the refusal.
- One object of a batch holding another’s root. A view’s save changes the nodes it holds. So a view saved with
Replace, whose root an earlier object of the batch holds, can be refused by the batch’s own doing. The exception then says so:StaleObjectException.bySameBatchis true. Give such a view before the objects that hold its root, or save them one at a time, each from what the save before it returned. - A batch the engine turns away because another writer was changing the same nodes is run again, as a save is.
- Null handling follows
NullPolicy, as forsave. onlyandexceptname fields of each object, and for a view fields of its root. Every other field of the node is left as the store has it: neither written nor, underNullPolicy.CLEAR, cleared. A view’s relationships are written whatever they name, and a related node is written whole. Give one or the other: both together are refused.- A field is named by its name.
Chunk::summarynames the fieldsummaryof every class in the batch, whichever class declares the property, so it is the same rule from Kotlin and from Java. This is wheresaveAlldiffers fromsave, which refuses a property of a class other than the one saved. Each name must be a field of every class in the batch, or the batch is refused before anything is written. The id and the stamp are refused inonly: neither is written from the object. A@PropertyBagor the@NodeLabelsfield can be named, and is then written as a save writes it. - With
onlyorexcept, a node that is gone comes back.saveAllis an upsert and a batch that only adds is not checked, so a node deleted since its object was loaded is made again, holding its id and the written fields alone, and for a view its relationships. It has its labels and a stamp, as any node a save makes. To write fields only where the node still exists, usepatchAll. - A batched statement is the same with fields named: each row, or on Oracle each step, carries those fields alone. A statement that saves one object, as for a view, sets those fields alone. A node gets a new stamp if a named field differs, if the save made it, or if it had none.
- The managers Drivine provides run the batch in one transaction. A
PersistenceManagerof your own that overridesexecuteBatchto do so must overridequeryBatchtoo, whichsaveAllcalls and whose default runs each statement on its own.
Conditional Write by Id (patch)
Section titled “Conditional Write by Id (patch)”Write named fields of one node by its id, if the node meets a predicate, in one statement. The node is not loaded: the store evaluates the predicate, so there is no second round trip and no loaded state to go stale. update is a load and then a checked save; save(only = …) needs the whole object, and cannot say “only if the stored value is older”.
Advancing a session’s activity, which must only move forward:
val result: PatchResult = graphObjectManager.patch<SessionData>(sessionId) { fields { query.lastActivityAt setTo activityAt } where { anyOf { query.lastActivityAt.isNull(); query.lastActivityAt lt activityAt } }}result.found // a node has the idresult.applied // the predicate held, so the fields were writtenresult.changed // a value differed, and the node's stamp was renewedThe three are nested, so there are four outcomes:
| Outcome | found |
applied |
changed |
The node’s stamp |
|---|---|---|---|---|
| No node has that id | false | false | false | there is no node |
| The node is there, and the predicate did not hold | true | false | false | as it was |
| The predicate held, and every value was already as set | true | true | false | as it was |
| The predicate held, and a value differed | true | true | true | new |
On a view the fields are the root’s, and the predicate may name a relationship, as a load’s does:
val narrated = graphObjectManager.patch<MessageInSession>(messageId) { fields { message.narration setTo narration } where { session.sessionId eq sessionId }}.applied // false for a message that is there, in another sessionfieldsnames what to write:property setTo value, andproperty.clear()to remove a property. A value is typed by its property, so a null or a value of another type does not compile. A fragment’s fields are reached asquery.x, a view’s by the name of its root (message.narration).whereis thewhereof a load, and optional: without one the fields are written wherever the node is. It binds a value as a load’s does, so a condition on a@JsonPackedfield compares the value as given, and not as the field packs it. On FalkorDB a moment is stored as text, with all nine digits of its second (2026-03-01T12:00:00.000000000Z), so two moments compare in order when their offsets agree, as those of anInstantdo. A release before this one wrote only the digits a moment had: a moment stored by one reads as before, but compares in order with a newer one, or as equal to it, only once it has been saved again.- It never makes a node. A patch of an id no node has writes nothing and returns
found = false. - It is not checked against a stamp, and hands none back. The predicate is read under the node’s lock, so what it compares of the node is what the last writer committed. A related node the predicate names is not locked. A node a patch changed has a new first token in its stamp, so an object loaded before the patch is refused when it is saved: load it again to go on working with it. The relationship token is left alone.
- A node whose values were already as set keeps its stamp, and none of the values is written to it. A node that never carried a stamp is given one, whatever the patch did to it. On the graph engines the lock is itself a property set and removed again, on every node the patch finds.
- A value is stored as the object mapper stores one of its type, which is how a save stores the field: an
Instant, an enum and a@VectorIndexvector, which FalkorDB keeps in its own vector type. A@JsonPackedfield is packed. - The id, the
@NodeStampfield, a key of a@PropertyBag, a@NodeLabelsfield and a field of a related node cannot be written. Nor can a field that carries@JsonSerializeor@JsonFormatbe set, since a save stores it as the annotation says and a lone value cannot be; it can be cleared. Each is refused before anything is sent, and so is a field named twice. - On a view,
foundsays the root node is there, and not that the view would load: a message with no session isfound, and notappliedwhen the predicate names the session. - The statement is applied whole or not at all, with or without a transaction, and is run again when the engine turns it away because another writer was changing the node. Inside a transaction it joins it. On Memgraph, inside a transaction that read the node before another writer changed it, the engine refuses the write itself and running it again cannot help: the patch fails, as a save does there.
- On Neo4j 4, which takes
EXISTS { }only in aWHERE, the node is locked and then found again by anOPTIONAL MATCHthat holds the predicate. The answers are the same. - The id is taken to be unique. Where two nodes of the class share one, the graph engines write each that meets the predicate, and Oracle the one it finds first.
patchis onStatelessGraphObjectManageralone. The generatedpatch<T>(id) { }passes the class’s query DSL object; without the code generator, callpatch(id, T::class.java, queryObject) { }.
Batch Patch (patchAll)
Section titled “Batch Patch (patchAll)”Write named fields of many nodes, where each node is there, and make no node. saveAll is an upsert, so a node deleted since its object was loaded comes back; patchAll leaves it gone and says so.
Re-embedding a page of messages, where a message may be deleted while the vectors are computed:
val embedded = page.mapIndexed { i, message -> message.copy(embedding = vectors[i], embeddingModel = modelName) }val results: List<PatchResult> = graphObjectManager.patchAll( embedded, only = setOf(MessageData::embedding, MessageData::embeddingModel),)val rewritten = results.count { it.found }- There is one
PatchResultfor each object, in the order given.foundsays whether the node was there, andchangedwhether a value written differed from what the node held.appliedequalsfound: it differs only for a patch with a predicate, whichpatchof one node has. onlyorexceptis required.exceptmeans every field that can be named, but those given. To write every field of an object, save it.- The id, the
@NodeStampfield, a@PropertyBagand a@NodeLabelsfield cannot be named. The names are checked against the class of the objects: with no objects, property references are checked where they are all of one fragment, and names given as strings are not. - The objects are where an id and values are read from, and no more. For a view the fields are its root’s, and its relationships are not written.
- A patch does not check stamps, and hands none back. Only the named fields are written, so there is nothing else of the node to overwrite. A node a patch changed has a new stamp, so an object loaded before the patch is then stale: load it again to go on working with it.
- A node whose values were already as set keeps its stamp, and on the graph engines none of the values is written to it, so a vector index is not fed a vector it holds. A node that never carried a stamp is given one.
- Under
NullPolicy.IGNORE, the default, a named field that is null on an object is left as the store has it. UnderCLEARit is cleared. - The objects are of one class, and each id is given once and is not null. A call that breaks one of these is refused before anything is written.
- The id is taken to be unique. Where two nodes of the class share one, the graph engines write both, and Oracle the one it finds first.
- The rows go 1,000 to a statement, and 100 where a
@VectorIndexfield is written, as one batch: atomic assaveAllis, run again when the engine turns it away for contention, and, where it is more than one statement, refused on FalkorDB inSTRICTmode. The rows are sent in the order of their ids, so two calls over the same nodes take their locks in the same order. - A
@VectorIndexfield is written in the engine’s own vector type, on FalkorDB too: a patch sets each field by name, where a batch save of such a fragment there is a statement for each.
Saving from Java
Section titled “Saving from Java”graphObjectManager.save(view);graphObjectManager.save(view, Replace.of(Set.of("assignedTo")));graphObjectManager.save(view, Replace.of(Set.of("assignedTo"), RemovedTargets.DELETE_UNREFERENCED));graphObjectManager.save(view, Replace.all());graphObjectManager.saveFields(person, Add.INSTANCE, NullPolicy.IGNORE, Set.of("name")); // only these fieldsgraphObjectManager.saveFields(chunk, Add.INSTANCE, NullPolicy.IGNORE, Set.of(), Set.of("embedding")); // every field but theseList<IssueView> saved = graphObjectManager.saveAll(views);graphObjectManager.saveAllFields(chunks, Add.INSTANCE, NullPolicy.IGNORE, Set.of("summary", "summaryModel")); // only these fields of eachList<PatchResult> patched = graphObjectManager.patchAllFields(chunks, NullPolicy.IGNORE, Set.of("embedding")); // nodes that are therePersonQueryDsl dsl = PersonQueryDsl.Companion.getINSTANCE();PatchResult touched = graphObjectManager.patch(id, Person.class, dsl, spec -> spec.set(dsl.getName().assign("Ada")).onlyIf(dsl.getBio().isAbsent())); // one node, if it meets the predicate
Person updated = graphObjectManager.update(id, Person.class, p -> { p.setName("Ada"); return p; }); // null when there is no such nodegraphObjectManager.update(id, Person.class, 5, p -> p.withName("Ada")); // five attemptsPerson made = graphObjectManager.create(person); // null when a node has its id
graphObjectManager.getEdges().unrelate(new NodeRef(Issue.class, a, Set.of()), new NodeRef(Person.class, b, Set.of()), "ASSIGNED_TO");- Java names fields as strings, so it calls
saveFieldswhere Kotlin passes property references tosave,saveAllFieldsforsaveAll, andpatchAllFieldsforpatchAll. - A
patchfrom Java names its fields withproperty.assign(value)andproperty.cleared()inspec.set(…), and its predicate with the conditions of the Java query builder:spec.onlyIf(…)joins them byAND, andspec.onlyIfAny(…)byOR. The DSL object above is the one the Kotlin code generator makes; one the Java generator makes isPersonQueryDsl.INSTANCE, withname()forgetName(). @NodeStampgoes on aStringfield of a Java class or on aStringcomponent of a record. A Java object whose fields can be set is given its new stamp in place, andsavereturns that same object. A record comes back as a copy.update’s function may change the object it is given and return it, or return another.- A refused save throws
StaleObjectException:getDeleted()says whether the node is gone,getFoundStamp()gives the stamp it carries now, andgetBySameBatch()says whether asaveAllrefused itself. Stamps.setClause("p")andStamps.linksClause("p")give theSETitems for Cypher of your own.
Saves and transactions
Section titled “Saves and transactions”- A save joins the transaction it is called in, Spring’s
@Transactionalor@DrivineTransactional. Outside one it is its own. StaleObjectExceptionis aRuntimeException, so a refusal that leaves a@Transactionalmethod rolls the transaction back. It is not aDrivineException: a handler that catches only that does not catch it.- A refused save has written nothing. What the transaction wrote before it is undone only if the transaction rolls back: code that catches the exception and carries on commits it.
- A checked save holds the node’s write lock until the transaction commits, so another writer to the same node waits for it.
- A statement the engine turns away because another writer was changing the same node is run again, six attempts in all, on Neo4j and Memgraph. Inside a transaction the engine has usually failed the transaction with the statement, so the later attempts fail too and the first failure is what the caller sees.
- On FalkorDB each statement commits by itself. A save is still atomic, being one statement, and so is a
saveAllof fragments alone that fits in one. Anupdateis two, a load and a checked save, and asaveAllthat holds a view is several: what ran before a failure or a refusal stays applied, unlessSTRICTmode refuses the batch before it runs.
See docs/0.1.0-stateless-object-manager.md for the release that introduced this manager and what it changed.

