Skip to content

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, n
total = await gom.count(Person)

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 any
q.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(...), and property(name) or predicate(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 == 1 raises: write q.x.eq(1).
  • Order and pages: order_by takes terms, asc() or desc(), then nulls_first() or nulls_last(); limit and skip apply 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 of order_by, ending with a unique key.
  • An order by an expression: plus, minus, times, div, negate, exp, floor, coalesce, case_of with above, below, at_least, at_most, equal_to, has_value and the rest, all computed in the database.
  • count, delete, delete_all and patch take conditions too.

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.pyi
python -m drivine.stubs myapp.models --check # in CI: fails when the models changed since

Then import refs from the module written:

from myapp.models_refs import refs
q = refs(PersonCareer) # PersonCareerRefs
q.person.bio.contains("Lead") # StringRef
q.person.born.gt("1815") # error: born is ComparableRef[int]
q.person.nmae # error: PersonNodes has no attribute nmae

Each 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.

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 None is required, so only roots that have it load; Person | None is optional. A list is required with Relationship(..., required=True).
  • Properties are stored in camel case, as TypeScript and JVM models store them: start_date as startDate. GraphProperty("name") names one, and property_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, Count and Aggregate computed in the query, SortedBy, ReadOnly, Transient, NodeStamp, NodeLabels, PropertyBag, JsonPacked, NullAsDefault and EmptyWhenAbsent, 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.

saved = await gom.save(career) # fields that aren't None; relationships added
await gom.save(career, replace=["employment_history"]) # this list is the whole list
await gom.save(person, only=["name"]) # or except_=["embedding"]
await gom.save(person, null_policy=NullPolicy.CLEAR) # a field assigned None clears its property
created = await gom.create(person) # only if absent; None when it was there
updated = 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,000
result = 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_UNREFERENCED also deletes a removed node that nothing else refers to.
  • Stamps refuse a stale save. On a class with a NodeStamp field, a save of an object loaded before another writer changed the node raises StaleObjectError and writes nothing. Use the object a save returns: it carries the new stamp. update loads, applies your change and saves what it altered, again if the node changed meanwhile.
  • save_all runs its statements in one transaction, or in yours, so a failure leaves nothing written.
  • patch writes 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.