Graph
The graph contract models a bounded context as typed node and edge kinds and gives
three ports over them: a query port (traverse), a command port (mutate), and a
raw port (an escape hatch for native queries). A GraphModuleSpec bundles the kinds;
refs (VertexRef / EdgeRef) address individual nodes and edges by kind + id.
q = ctx.graph.query(spec) # traverse
c = ctx.graph.command(spec) # mutate
r = ctx.graph.raw(spec) # native-query escape hatch
Spec¶
GraphModuleSpec — a module-level bundle of vertex and edge kinds:
| Field | Type | Meaning |
|---|---|---|
name |
str \| StrEnum |
the graph area / module name |
nodes |
tuple[GraphNodeSpec, ...] |
the vertex kinds (each a named model) |
edges |
tuple[GraphEdgeSpec, ...] |
the edge kinds (each a named model) |
Each GraphNodeSpec / GraphEdgeSpec carries a name (the kind), a read model, and
optional create / update models. An edge also declares its identity ("key" or
"endpoints"), its allowed endpoints, and its directionality (directed /
symmetric). Field encryption covers nodes and
key-addressed edges; endpoint-identity edges reject binds_record_id.
Choosing an edge identity¶
This is the one modelling decision the graph contract really asks of you, and it is not a question about whether the edge happens to have an id field. It is: what makes one of these edges the same edge?
identity |
Means | Then |
|---|---|---|
"endpoints" |
At most one edge of this kind per (from, to) pair. The pair is the identity. |
EdgeRef.by_endpoints(...) addresses it. A second create_edge on the same pair raises conflict — use ensure_edge to leave the existing one alone, or update_edge to change it. |
"key" (default) |
Each edge has a business key of its own (key_field). |
EdgeRef.by_key(...) addresses it. Parallel edges between the same pair are allowed — they are distinct entities with distinct keys. |
So: if two edges of a kind can legitimately run between the same pair — two flights between
two cities, two roads between two towns — the kind is "key", not "endpoints". That is not
a workaround for a limitation; an edge that is a distinct entity needs a key to be one.
Declaring such a kind "endpoints" leaves it with no identity at all: get_edge would return an
arbitrary one of the parallel edges and update_edge / delete_edge would hit every one.
create_edge enforces this: on an endpoint-identified kind, a second create on a pair that
already has an edge raises conflict rather than laying a parallel one. A malformed spec —
identity="key" with no key_field, an endpoint naming a node kind that does not exist, a key
field absent from its own read model — is refused when the GraphModuleSpec is constructed.
Finding pairs that already carry parallel edges
create_edge refuses to make them, but a graph written before it did not, and the raw query
hatch (or any writer that is not forze) still can. The pair cursor surfaces them for free —
it yields one batch per (from, to) pair, so a batch with more than one edge is a
violating pair:
async for batch in ctx.graph.query(SOCIAL).find_edges_stream("FOLLOWS", chunk_size=1):
if len(batch) > 1:
... # this pair carries parallel edges — decide which one is real
Worth running once after upgrading. The framework cannot pick for you, and an export will carry all of them (it carries the graph it finds, not the graph the spec hoped for).
Query port (ctx.graph.query(spec))¶
| Method | Notes |
|---|---|
get_vertex(ref) / get_vertices(refs) |
fetch nodes by ref |
get_edge(ref) / get_edges(refs) |
fetch edges by ref |
vertex_exists(ref) / edge_exists(ref) |
presence checks |
find_vertices(node_kind, *, property_filter=None, limit=100, offset=0) |
list nodes of a kind, optionally filtered by properties |
find_edges(edge_kind, *, property_filter=None, limit=100, offset=0) |
list edges of a kind |
count_vertices(node_kind, *, property_filter=None) / count_edges(edge_kind, …) |
counts by kind |
neighbors(origin, direction, edge_kinds, *, limit, to_vertex_kinds=None) |
adjacent nodes with the connecting edge, filtered by kind and direction. limit truncates to an arbitrary subset — see below |
incident_edges(origin, direction, edge_kinds, *, limit) |
the edges touching a node |
vertex_degree(ref, …) / count_neighbors(ref, …) |
degree / distinct-neighbor counts by direction and edge kind |
expand(start, params) |
breadth-limited walk (GraphWalkParams: max_depth, max_results, direction, edge kinds) returning GraphWalkSteps |
scoped_walk(anchor, params) |
multi-step typed traversal (ScopedWalkParams: a tuple of GraphPathSteps with per-step hop bounds, a target_kind, a limit) |
shortest_path(from_ref, to_ref, params) |
one path (ShortestPathParams: max_hops, edge_kinds, optional weight_property for weighted/native paths — needs a GDS-capable backend, else graph_algorithm_unavailable) |
k_shortest_paths(from_ref, to_ref, params, *, k) |
the k best paths, same params |
Streaming a whole kind¶
find_vertices and find_edges page by offset, which is fine for a screenful and wrong
for a sweep: SKIP n counts rows from the start of a result set that is being written
underneath it, so a node created before the cursor shifts every later row one place along and
the next page steps over one. For a migration or an export, a skipped page and an empty page
produce the same artifact.
The streaming reads seek by key instead — key_field > last-seen — so a bookmark cannot move:
| Method | Notes |
|---|---|
find_vertices_stream(node_kind, *, property_filter=None, chunk_size=500) |
async generator of keyset batches; walks the kind to exhaustion |
find_edges_stream(edge_kind, *, property_filter=None, chunk_size=500) |
same, for an edge kind of either identity |
async for batch in ctx.graph.query(SOCIAL).find_vertices_stream("User", chunk_size=500):
... # memory is bounded by chunk_size, whatever the graph's size
What an edge bookmarks on. A keyed edge (identity="key") bookmarks on its own key. An
edge declared identity="endpoints" has no key of its own — that is what the declaration means
— so it bookmarks on the (tail, head) node-key pair, which is the identity the author
asserted. For such a kind, chunk_size bounds pairs, not edges, and every edge of a pair
is yielded together: nothing enforces the one-edge-per-pair identity (create_edge will add a
second parallel edge), so a page cut within a pair would leave edges behind a cursor that
seeks strictly past it.
They fail closed rather than serve a scan that looks complete and is not
(graph_streaming_unsupported):
- a backend that does not report
GraphReadCapabilitiessupports neither stream; - a multi-endpoint edge kind whose endpoint node kinds key on different properties — a
TAGGEDkind linkingPost → TagandNote → TagwherePostandNotekey differently has no single ordering that covers both.
A key field may not be encrypted
Naming a kind's key_field in its own encryption policy is refused at spec construction
(graph_sealed_key_field). A sealed key is not a key. A lookup by key compares the
caller's plaintext against what the write sealed, so the two never meet: a vertex
created under a sealed key could never be fetched, updated or deleted by that key again —
a write-only black hole. It also has no order to page or seek on. Encrypting an ordinary
property is entirely fine; this is about the key alone.
A key field must be a string
VertexRef.key is a str, so a key_field declared as bool, int or float is
refused at spec construction (graph_non_string_key_field). A store that keeps the
property in its native type never matches the string a keyed lookup binds — measured on
Neo4j with an int key, the writes land and find_vertices returns the rows while
vertex_exists, get_vertex, vertex_degree and neighbors all answer as though
the vertex were not there. No error, just an empty graph, and the in-memory mock keys its
store by str(value) so everything works there. Use a str key — or a UUID or
str-valued enum, which reach the store as text — and keep the number as an ordinary
property.
A Decimal key is allowed: properties are written through model_dump(mode="json"),
which renders it as a string, so Neo4j holds STRING and every keyed read resolves —
exponent and high-precision forms included, byte-exact. Note that the key is that text:
Decimal("1.50") and Decimal("1.5") are two different keys even though Python calls
them equal.
A bounded neighbors call returns an arbitrary subset
When limit is smaller than the neighbourhood, which neighbours come back is the engine's
choice: Neo4j's adjacency LIMIT carries no ORDER BY, and the mock walks its edge list in
insertion order. The differential compares how many arrive, not which — asking Neo4j for a
deterministic page would mean sorting a whole neighbourhood before truncating it, and the
cost lands hardest on the high-degree vertices a limit exists to protect.
What you can rely on is that the page is full: a to_vertex_kinds filter is applied
before the limit on every backend, so a short page means the neighbourhood really is
exhausted. That distinction is load-bearing — the rows a filter drops are not a cursor you
can page past, so a page cut short by filtering would read as "there are no more".
Command port (ctx.graph.command(spec))¶
Every create/ensure takes return_new: bool = True.
| Method | Notes |
|---|---|
create_vertex(node_kind, cmd) / create_vertices(items) |
add nodes |
create_edge(edge_kind, cmd) / create_edges(items) |
add edges |
ensure_vertex(node_kind, cmd) / ensure_edge(edge_kind, cmd) |
insert-when-missing (idempotent by identity) |
update_vertex(ref, cmd) / update_edge(ref, cmd) |
patch by ref |
delete_vertex(ref) / delete_vertices(refs) |
remove nodes |
delete_edge(ref) / delete_edges(refs) |
remove edges |
Raw port (ctx.graph.raw(spec))¶
run(query, params=None) executes a native query (Cypher) — the escape hatch for
traversals the typed ports don't express.
Management port (ctx.graph.management(spec))¶
Control-plane schema provisioning, separate from the data-plane command port:
ensure_schema() creates constraints/indexes for the declared kinds; drop_schema()
removes them. Both are idempotent, and safe to call in either order — a teardown that
runs before anything was provisioned succeeds.
On Neo4j this is required for correctness, not just speed: node-key uniqueness is the
constraint ensure_schema() creates, and without it Cypher CREATE will happily write a
second node under a key that is supposed to identify one. Run it at startup.
The mock implements the port so that startup step is exercisable in tests, but both
methods are no-ops there: the in-memory store keys vertices directly, so uniqueness is a
property of the data structure and drop_schema() cannot take it away. Observing what an
engine does without its constraints needs that engine.
Implemented by¶
| Backend | Integration |
|---|---|
| Neo4j | Neo4j |
A mock implements the surface for tests.