Fragments and views
Map a node with a NodeFragment, and compose fragments and relationships into a GraphView, which loads in one
statement:
from typing import Annotated
from drivine import GraphView, NodeFragment, NodeId, Relationship, RelationshipFragment, Root, TargetNode
class Person(NodeFragment, labels=["Person"]): uuid: Annotated[str, NodeId()] name: str bio: str | None = None
class Organization(NodeFragment, labels=["Organization"]): uuid: Annotated[str, NodeId()] name: str
class WorkHistory(RelationshipFragment): # a relationship's properties, and the node it leads to start_date: str role: str target: Annotated[Organization, TargetNode()]
class PersonCareer(GraphView): person: Annotated[Person, Root()] employment_history: Annotated[list[WorkHistory], Relationship("WORKS_FOR")] = []gom = drivine.graph_object_manager() # from DrivineAssembly, or StatelessGraphObjectManager(pm)
career = await gom.load(PersonCareer, uuid)leads = await gom.load_all(PersonCareer, "n.bio CONTAINS $text", {"text": "Lead"}) # Cypher over the root, ntotal = await gom.count(Person)Queries
Section titled “Queries”A query is built from a class’s references, which mirror its fields:
from drivine import any_of, not_, refs
q = refs(PersonCareer)leads = await gom.load_all( PersonCareer, where=q.person.bio.contains("Lead"), order_by=q.person.name.asc(), limit=20,)
await gom.load_all(PersonCareer, where=[ # a list: all must hold q.employment_history.target.name.eq("Acme"), # across a relationship any_of(q.person.name.eq("Ada"), q.person.name.eq("Grace")), not_(q.person.bio.is_null()),])
# one relationship that meets both, rather than each met by anyq.employment_history.any(lambda it: [it.role.eq("Engineer"), it.target.name.eq("Acme")])q.employment_history.none(lambda it: it.role.eq("Lead"))- Operators:
eq,neq,gt,gte,lt,lte,in_,not_in,contains,starts_with,ends_with,matches,contains_ignore_case,eq_ignore_case,has_item,is_null,is_not_null. On a node:instance_of(Type),has_any_label(...), andproperty(name)orpredicate(name, operator, value)for a property named at run time by its stored name. - Fields go by their Python names,
q.start_date, and Drivine writes the stored names. A field the class doesn’t have fails as the query is written, naming it and the class.q.x == 1raises: writeq.x.eq(1). - Order and pages:
order_bytakes terms,asc()ordesc(), thennulls_first()ornulls_last();limitandskipapply in the database. An order on a list relationship’s property sorts that list in each object. - Keyset pages:
seek=[q.released.after(last.released), q.uuid.after(last.uuid)], one value for each term oforder_by, ending with a unique key. - An order by an expression:
plus,minus,times,div,negate,exp,floor,coalesce,case_ofwithabove,below,at_least,at_most,equal_to,has_valueand the rest, all computed in the database. count,delete,delete_allandpatchtake conditions too.
Typed references
Section titled “Typed references”refs is untyped: Python can’t derive a reference type for each field from a class, as TypeScript does. A
misspelt field still fails, but when the query is written. To have pyright or mypy check each query, and an editor
complete its fields, write typed references for your models:
python -m drivine.stubs myapp.models # writes myapp/models_refs.py and models_refs.pyipython -m drivine.stubs myapp.models --check # in CI: fails when the models changed sinceThen import refs from the module written:
from myapp.models_refs import refs
q = refs(PersonCareer) # PersonCareerRefsq.person.bio.contains("Lead") # StringRefq.person.born.gt("1815") # error: born is ComparableRef[int]q.person.nmae # error: PersonNodes has no attribute nmaeEach field’s reference follows from its type: text is a StringRef; numbers, dates and times are a
ComparableRef; booleans and enums a ValueRef; lists and sets a ListRef; a property bag gives any entry; and a
relationship leads to the fields of the nodes it reaches, with any and none. At run time the module is drivine’s
refs, so nothing changes but what the checker knows. Several modules can share one: python -m drivine.stubs myapp.models myapp.billing --out myapp/query_refs.py.
Grouped reads
Section titled “Grouped reads”Group a fragment’s nodes by keys, with numbers about each group, in one statement:
from drivine import avg, count, hop, key, path_key, refs
q = refs(Review)top = await gom.groups.load( Review, keys={"page": key(q.page)}, # or path_key(Page, [hop("REVIEWS")]): the node aggregates={"votes": count(q.helpful.eq(True)), "rating": avg(q.rating)}, where=q.published.is_not_null(), having=[("votes", "gte", 3)], order_by=[("votes", "desc")], limit=10,)# [{"page": "/pricing", "votes": 12, "rating": 4.5}, ...]
await gom.groups.count_by(Review, "page") # {"/pricing": 14, ...}Aggregates are count, sum_, avg, min_ and max_, each with an optional filter. Ties in an order are broken by
the keys, so a page of groups is the same every time.
- Models are Pydantic. A loaded fragment is validated, so a bad value fails at the load.
- The annotation says what a relationship holds: a fragment, a relationship fragment or a view, one or a list. A
single relationship typed without
Noneis required, so only roots that have it load;Person | Noneis optional. A list is required withRelationship(..., required=True). - Properties are stored in camel case, as TypeScript and JVM models store them:
start_dateasstartDate.GraphProperty("name")names one, andproperty_names="as_written"keeps a class’s fields as they are written. - A subclass is loaded as itself. A fragment’s labels are inherited, and a node loads as the most specific subclass whose labels it carries.
- Recursion:
Relationship("HAS_LOCATION", max_depth=3)on a list of the view’s own class expands three levels;depth={"sub_locations": 5}overrides it for one load. - More markers:
GraphPath([hop(...), ...])for the nodes at the end of several hops,CountandAggregatecomputed in the query,SortedBy,ReadOnly,Transient,NodeStamp,NodeLabels,PropertyBag,JsonPacked,NullAsDefaultandEmptyWhenAbsent, as in TypeScript’s Drivine.
The statements are TypeScript’s, character for character: the tests check them against those TypeScript’s Drivine writes for the same models.
Saving
Section titled “Saving”saved = await gom.save(career) # fields that aren't None; relationships addedawait gom.save(career, replace=["employment_history"]) # this list is the whole listawait gom.save(person, only=["name"]) # or except_=["embedding"]await gom.save(person, null_policy=NullPolicy.CLEAR) # a field assigned None clears its propertycreated = await gom.create(person) # only if absent; None when it was thereupdated = await gom.update(Person, uuid, lambda it: it.model_copy(update={"name": "Ada"}))await gom.delete(PersonCareer, uuid, cascade=CascadeType.DELETE_ORPHAN)await gom.delete_all(Person, "n.name = $name", {"name": "Ada"})
await gom.save_all(chunks) # fragments in one statement per 1,000result = await gom.patch(Session, session_id, set={"last_activity_at": now}, where="n.lastActivityAt IS NULL OR n.lastActivityAt < $now", bindings={"now": now})results = await gom.patch_all(chunks, only=["embedding"]) # named fields, where each node is there
await gom.edges.relate(node_ref(Issue, a), node_ref(Person, b), "ASSIGNED_TO")await gom.edges.unrelate(node_ref(Issue, a), node_ref(Person, b), "ASSIGNED_TO")people = await gom.edges.load_related(node_ref(Issue, a), "ASSIGNED_TO", Person)- A save is one statement, applied whole or not at all: the root, the relationships it drops, each related node and the relationship to it.
- A save adds and removes nothing, unless a field is named in
replace.removed_targets=DELETE_UNREFERENCEDalso deletes a removed node that nothing else refers to. - Stamps refuse a stale save. On a class with a
NodeStampfield, a save of an object loaded before another writer changed the node raisesStaleObjectErrorand writes nothing. Use the object a save returns: it carries the new stamp.updateloads, applies your change and saves what it altered, again if the node changed meanwhile. save_allruns its statements in one transaction, or in yours, so a failure leaves nothing written.patchwrites named fields of one node by id, if it meets a condition, without loading it, and says whether it found the node, applied the patch and changed anything. It never makes a node.- Edges relate and unrelate stored nodes by id, marking both ends’ stamps.
- Outside a transaction, a save the engine turns away because another writer held the node is run again after a pause. Inside one, it joins the transaction, and fails for its owner to decide.
The store format, stamps included, is TypeScript’s and drivine4j’s, so the three can share a store.

