Skip to content

Saving Data

A save writes what the object holds. It doesn’t depend on whether the object was loaded, or on anything the manager remembers, because the manager remembers nothing.

await gom.save(view); // every field that is set; relationships are added, never removed
await gom.save(view, { replace: ['assignedTo'] }); // this field's list is the whole list
await gom.save(view, { replace: 'all' }); // every relationship field's list is the whole list
await gom.save(chunk, { except: ['embedding'] }); // write everything but these
await gom.save(person, { only: ['name'] }); // write just these
await gom.update(Person, id, (it) => { it.name = 'Ada'; }); // load, change, save what differs
await gom.create(person); // make it only if it is not there; undefined when it is
await gom.edges.unrelate(nodeRef(Issue, a), nodeRef(Person, b), 'ASSIGNED_TO');

A save is one 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 every engine.

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, not both.

save returns the saved object. On a class 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.

A save adds the relationships the object holds and removes none. To remove, name the field in replace: its 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.
  • removedTargets: RemovedTargets.DELETE_UNREFERENCED also deletes a removed target that nothing else refers to the way the field did. The root is never deleted, nor is a node the object still holds in another field.
  • 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 not what the store holds.
  • A list to replace that is null or undefined is refused. An empty list removes every relationship of the field, and a single-valued field that is null or undefined removes its relationship.
  • replace is refused, on a root that carries a stamp, if any relationship of the root was added, removed or 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.
  • An UNDIRECTED field is met by a relationship stored either way. One is made, from the root, only when there is none.
  • A read-only field is never written: a @ReadOnly() field, a @GraphPath, a @Count or @Aggregate, and a list of fragments read over several hops.

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. Your change may alter the object it is given, or return a copy.

const saved = await gom.update(Person, id, (person) => {
person.name = 'Ada';
});
  • If the node changed between the load and the save, update loads it again and re-applies the change: three attempts by default (the fourth argument), then StaleObjectException. That needs a @NodeStamp field; without one a change by another writer goes unnoticed.
  • It returns undefined when there is no such node, or the node is deleted in between. It never makes a node.
  • A change that gives the object another id or another class is refused.

create makes the node only if no node has its id, in one statement. If one does, it writes nothing and returns undefined. With update, that saves an aggregate without losing any writer’s data:

const stored =
(await gom.create(proposition)) ?? (await gom.update(Proposition, proposition.id, (it) => it.merge(proposition)));
  • An object that carries a stamp was loaded, and is refused.
  • Two writers at the same moment can both find the node absent. On Neo4j, Memgraph and Oracle only a uniqueness constraint on the id keeps the second from making the node again: uniquenessConstraint('Proposition', 'id'). FalkorDB runs one write at a time, so there the second always finds the first’s node.
await gom.save(chunk); // IGNORE (default): merge-patch
await gom.save(chunk, { nullPolicy: NullPolicy.CLEAR }); // a full overwrite: nulls clear
  • IGNORE writes only fields that are set; a null or undefined field leaves the stored value. A partly-loaded object never destroys stored data, an embedding included.
  • CLEAR makes the object authoritative: a null field clears its property. In JavaScript only keys the object holds can be seen, so a declared field that was never assigned is not cleared. Use it with a complete object.

In a view the policy governs the root. A related node is written as under IGNORE.

const saved = await gom.saveAll(views);
await gom.saveAll(views, { replace: ['mentions'] });
await gom.saveAll(chunks, { only: ['summary', 'summaryModel'] });
  • Fragments go in one statement for each 1,000, of any classes and labels. A view is a statement of its own. Outside a transaction of yours the statements run in one transaction, so a failure leaves nothing written; inside one they join it. FalkorDB has no transactions: see FalkorDB.
  • The objects come back in the order given, carrying the stamps the batch left.
  • A batch that only adds doesn’t check a fragment’s stamp: a stale one is written anyway, and keeps the stamp it had, so a later save of it is refused.
  • replace applies to views; a batch holding a fragment is refused with it. A stale view throws StaleObjectException, and the batch then writes nothing.
  • With only or except, a node deleted since its object was loaded is made again. To write only where the node still exists, use patchAll.

Write named fields of one node, by its id, if the node meets a condition, in one statement. The node isn’t loaded: the store evaluates the condition under the node’s lock.

const result: PatchResult = await gom.patch(Session, sessionId, {
set: { lastActivityAt: activityAt },
where: (q) => anyOf(q.lastActivityAt.isNull(), q.lastActivityAt.lt(activityAt))
});
result.found; // a node has the id
result.applied; // the condition held, so the fields were written
result.changed; // a value differed, and the node's stamp was renewed
Outcome found applied changed
No node has that id false false false
The condition didn’t hold true false false
It held, and every value was already as set true true false
It held, and a value differed true true true

On a view the fields are the root’s, and the condition may name a relationship:

const narrated = (await gom.patch(MessageInSession, messageId, {
set: { narration },
where: (q) => q.session.sessionId.eq(sessionId)
})).applied; // false for a message that is there, but in another session
  • set names fields to write, and clear fields to remove.
  • It never makes a node, isn’t checked against a stamp, and hands none back. A node it changed has a new stamp, so an object loaded before is refused when saved: load it again.
  • The id, the stamp, a bag’s key, a @NodeLabels field and a related node’s field can’t be written.

Write named fields of many nodes, where each is there, making none:

const results = await gom.patchAll(embedded, { only: ['embedding', 'embeddingModel'] });
const rewritten = results.filter((it) => it.found).length;

There is one PatchResult for each object, in order. only or except is required: to write every field, save. Under NullPolicy.IGNORE a named field that is null is left as the store has it.

  • A save joins the transaction it runs in, a @Transactional() method’s or runInTransaction’s. Outside one it is its own.
  • StaleObjectException leaving a @Transactional() method rolls the transaction back. A refused save has written nothing; what the transaction wrote before it is undone only if the transaction rolls back.
  • A statement the engine turns away because another writer was changing the same node is run again, after a pause, outside a transaction. Inside one it fails, and the transaction’s owner decides.