Skip to content

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.Replace
import org.drivine.manager.RemovedTargets
import org.drivine.manager.nodeRef
import org.drivine.manager.update // the reified update<T>(id) { }
graphObjectManager.save(view) // every non-null field; relationships are added, never removed
graphObjectManager.save(view, Replace(IssueView::assignedTo)) // this field's list is the whole list
graphObjectManager.save(view, Replace.all()) // every relationship field's list is the whole list
graphObjectManager.save(chunk, except = setOf(Chunk::embedding)) // write everything but these
graphObjectManager.save(person, only = setOf(Person::name)) // write just these
graphObjectManager.update<Person>(id) { it.copy(name = "Ada") } // load, change, save what differs
graphObjectManager.create(person) // make it only if it is not there; null when it is
graphObjectManager.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.

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.

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 @NodeStamp field 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 with Replace.
  • 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 Replace or covered by Replace.all(), removes the relationship: for one node, null is how “none” is said.
  • Replace trusts that the list came from a load. A list cut short by a custom query is taken as the whole list.
  • Replace is 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 UNDIRECTED field 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_UNREFERENCED never 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. update clears 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, and edges.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.

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, update loads it again and re-applies your change: three attempts in all by default, and then StaleObjectException is thrown.
  • That needs a @NodeStamp field. Without one a change by another writer is not noticed.
  • The id is a String or a UUID.
  • update returns 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 StaleObjectException that 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 @NodeLabels field adds a read of the labels the node records as its own.

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 save does, or null when a node has the id. It is then for update to 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 save makes it: its root, each related node and the relationship to it. A related node is written unchecked, as save writes it, so one that was already there takes the object’s fields.
  • An object that carries a stamp is refused, with IllegalArgumentException and before anything is written: a stamp comes from a node that was loaded, and create makes 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. create joins 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 its create: run that transaction again, and its create returns null. Where the engine refuses the statement itself and the node is then found, create returns null, but the engine may already have marked the transaction for rollback, so an update after it in the same transaction fails. Run the transaction again.
  • The node is found by every label of the object’s class, as a save finds it. A Manager labelled Worker:Manager is found by create(worker), but a node labelled only Worker is not found by create(manager). Without a constraint, a second node is made. With a uniqueness constraint on a label the two share, the store refuses it and create throws.
  • The statement is run again when the engine turns it away because another writer was changing the node, as a save is.
  • create is on StatelessGraphObjectManager alone, as save and update are. There is no createAll: make each node with its own create.

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.

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-patch
graphObjectManager.save(chunk, nullPolicy = NullPolicy.CLEAR) // full overwrite: nulls clear
  • IGNORE (default) — writes only non-null fields; nulls are left untouched. A partially-loaded object never destroys stored data — including a @VectorIndex embedding (a ChunkNode reconstructed 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.

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 each
graphObjectManager.saveAll(chunks, except = setOf(Chunk::embedding)) // write everything but these
  • Fragments collapse into chunked UNWIND … MERGE statements (sub-linear round trips), with or without a @NodeStamp field. A view is saved by a statement of its own, as save does it.
  • Heterogeneous collections are grouped by runtime class, and the returned list preserves input order.
  • A fragment with a @PropertyBag or a @NodeLabels field is saved one statement each. So, on FalkorDB, is a fragment with a @VectorIndex field, 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 save of it would be refused. The object handed back then keeps the stamp it had, so a later save of 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 Replace applies to views: a batch that holds a fragment is refused with it. Each view that carries a stamp is checked as save checks it, and a stale one throws StaleObjectException. 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.bySameBatch is 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 for save.
  • only and except name 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, under NullPolicy.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::summary names the field summary of every class in the batch, whichever class declares the property, so it is the same rule from Kotlin and from Java. This is where saveAll differs from save, 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 in only: neither is written from the object. A @PropertyBag or the @NodeLabels field can be named, and is then written as a save writes it.
  • With only or except, a node that is gone comes back. saveAll is 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, use patchAll.
  • 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 PersistenceManager of your own that overrides executeBatch to do so must override queryBatch too, which saveAll calls and whose default runs each statement on its own.

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 id
result.applied // the predicate held, so the fields were written
result.changed // a value differed, and the node's stamp was renewed

The 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 session
  • fields names what to write: property setTo value, and property.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 as query.x, a view’s by the name of its root (message.narration).
  • where is the where of 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 @JsonPacked field 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 an Instant do. 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 @VectorIndex vector, which FalkorDB keeps in its own vector type. A @JsonPacked field is packed.
  • The id, the @NodeStamp field, a key of a @PropertyBag, a @NodeLabels field and a field of a related node cannot be written. Nor can a field that carries @JsonSerialize or @JsonFormat be 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, found says the root node is there, and not that the view would load: a message with no session is found, and not applied when 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 a WHERE, the node is locked and then found again by an OPTIONAL MATCH that 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.
  • patch is on StatelessGraphObjectManager alone. The generated patch<T>(id) { } passes the class’s query DSL object; without the code generator, call patch(id, T::class.java, queryObject) { }.

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 PatchResult for each object, in the order given. found says whether the node was there, and changed whether a value written differed from what the node held. applied equals found: it differs only for a patch with a predicate, which patch of one node has.
  • only or except is required. except means every field that can be named, but those given. To write every field of an object, save it.
  • The id, the @NodeStamp field, a @PropertyBag and a @NodeLabels field 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. Under CLEAR it 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 @VectorIndex field is written, as one batch: atomic as saveAll is, run again when the engine turns it away for contention, and, where it is more than one statement, refused on FalkorDB in STRICT mode. 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 @VectorIndex field 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.
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 fields
graphObjectManager.saveFields(chunk, Add.INSTANCE, NullPolicy.IGNORE, Set.of(), Set.of("embedding")); // every field but these
List<IssueView> saved = graphObjectManager.saveAll(views);
graphObjectManager.saveAllFields(chunks, Add.INSTANCE, NullPolicy.IGNORE, Set.of("summary", "summaryModel")); // only these fields of each
List<PatchResult> patched = graphObjectManager.patchAllFields(chunks, NullPolicy.IGNORE, Set.of("embedding")); // nodes that are there
PersonQueryDsl 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 node
graphObjectManager.update(id, Person.class, 5, p -> p.withName("Ada")); // five attempts
Person 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 saveFields where Kotlin passes property references to save, saveAllFields for saveAll, and patchAllFields for patchAll.
  • A patch from Java names its fields with property.assign(value) and property.cleared() in spec.set(…), and its predicate with the conditions of the Java query builder: spec.onlyIf(…) joins them by AND, and spec.onlyIfAny(…) by OR. The DSL object above is the one the Kotlin code generator makes; one the Java generator makes is PersonQueryDsl.INSTANCE, with name() for getName().
  • @NodeStamp goes on a String field of a Java class or on a String component of a record. A Java object whose fields can be set is given its new stamp in place, and save returns 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, and getBySameBatch() says whether a saveAll refused itself.
  • Stamps.setClause("p") and Stamps.linksClause("p") give the SET items for Cypher of your own.
  • A save joins the transaction it is called in, Spring’s @Transactional or @DrivineTransactional. Outside one it is its own.
  • StaleObjectException is a RuntimeException, so a refusal that leaves a @Transactional method rolls the transaction back. It is not a DrivineException: 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 saveAll of fragments alone that fits in one. An update is two, a load and a checked save, and a saveAll that holds a view is several: what ran before a failure or a refusal stays applied, unless STRICT mode 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.