Graph queries
Graph queries walk relationships between rows. Examples: "all customers a supplier sells to", "friends of friends", "shortest path from order to fulfillment center". You declare which columns are relationships on the schema, then the graph endpoints walk them.
Edges are not stored separately - they're a view over your existing row columns. Writing a row creates / updates / removes the edges automatically. See Schemas → relations for the declaration.
1. Declare a relation.
Add a [[relations]] block to the schema of the table that holds the foreign-key column. After registering this schema, every row write creates the edges automatically.
# manifest.toml - declare the relation on the table holding the FK column.
# The reverse edge is written atomically when bidirectional = true.
namespace = "social"
table = "follows"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "follower_id"
ty = "str"
required = true
[[columns]]
name = "followee_id"
ty = "str"
required = true
[[relations]]
name = "followee" # the verb you walk in queries
from_col = "followee_id" # column on THIS table
bidirectional = true
[relations.target]
namespace = "social"
table = "users"
pk = "id" Self-relations work - direction tags in the key prevent collisions. See Schemas → relations for the full reference.
2. One-hop neighbors.
Return the primary keys of every row directly connected to a given row through a relation. The fastest graph query - just an adjacency list lookup.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.follows/neighbors?rel=followee&pk=f001" \
-H "Authorization: Bearer $OC_TOKEN"neighbors = db.graph.neighbors(
"social.follows",
rel="followee",
pk="f001",
)
for n in neighbors:
print(n.pk)const neighbors = await db.graph.neighbors("social.follows", {
rel: "followee",
pk: "f001",
});neighbors, err := db.Graph().Neighbors(ctx, "social.follows", originchain.NeighborsRequest{
Rel: "followee",
PK: "f001",
})
For inbound edges (who points at this row?), use the parallel /reverse endpoint with the same params.
3. BFS - multi-hop traversal.
Walk every node reachable within max_depth hops, breadth-first. Each result carries the hop distance.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.follows/bfs?rel=followee&pk=u001&max_depth=3" \
-H "Authorization: Bearer $OC_TOKEN"hits = db.graph.bfs(
"social.follows",
rel="followee",
pk="u001",
max_depth=3,
)
for h in hits:
print(h.pk, h.depth)const hits = await db.graph.bfs("social.follows", {
rel: "followee",
pk: "u001",
max_depth: 3,
});hits, err := db.Graph().BFS(ctx, "social.follows", originchain.BFSRequest{
Rel: "followee",
PK: "u001",
MaxDepth: 3,
})- Forgetting max_depth. On a connected graph, BFS without a depth cap can return millions of nodes. Set
max_depthto the smallest value that gives you what you need. - Using BFS when you just want reachability. If you only need to know whether two nodes are connected, use
/path- it short-circuits on first match.
4. Shortest path (Dijkstra).
Find the lowest-cost path from src to dst. You provide a map of edge weights; the engine returns the total cost (or null if unreachable).
# weights_json maps "from_pk|to_pk" → cost. Empty {} = every edge weight 1.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.follows/dijkstra?rel=followee&src=u001&dst=u042&weights_json=%7B%7D" \
-H "Authorization: Bearer $OC_TOKEN"result = db.graph.dijkstra(
"social.follows",
rel="followee",
src="u001",
dst="u042",
weights={}, # default weight 1 per edge
)
print(result.cost) # None if unreachableconst result = await db.graph.dijkstra("social.follows", {
rel: "followee",
src: "u001",
dst: "u042",
weights: {},
});
console.log(result.cost); // null if unreachableresult, err := db.Graph().Dijkstra(ctx, "social.follows", originchain.DijkstraRequest{
Rel: "followee",
Src: "u001",
Dst: "u042",
Weights: map[string]float64{},
})
For top-K paths instead of the single shortest, use /k-shortest (Yen's algorithm). For unweighted shortest path, use /path.
5. All 22 endpoints.
Every graph endpoint, its wire shape, and what it's good for. Centrality and community algorithms (betweenness, eigenvector, label-propagation, triangles, louvain) require same-schema relations - cross-schema is not yet supported.
| operation | signature | use |
|---|---|---|
| neighbors | GET /graph/:schema/neighbors?rel=&pk= | One-hop forward - downstream of a node. |
| reverse | GET /graph/:schema/reverse?rel=&pk= | One-hop inbound - who points TO this node? |
| bfs | GET /graph/:schema/bfs?rel=&pk=&max_depth= | Breadth-first frontier up to a depth. |
| path | GET /graph/:schema/path?rel=&src=&dst=&max_depth= | Reachability check - short-circuits on first match. |
| all_simple_paths | GET /graph/:schema/all_simple_paths?rel=&src=&dst=&max_depth=&max_paths= | Every acyclic (simple) route between two nodes. Default cap 256 paths. |
| all_simple_paths_bidir | GET /graph/:schema/all_simple_paths_bidir?rel=&src=&dst=&max_depth=&max_paths= | Same, walking each hop in either direction. Needs bidirectional = true. |
| dijkstra | GET /graph/:schema/dijkstra?rel=&src=&dst=&weights_json= | Weighted shortest path. |
| k-shortest | GET /graph/:schema/k-shortest?rel=&source=&target=&k= | Top-K loop-free paths in increasing weight. |
| pagerank | GET /graph/:schema/pagerank?rel=&nodes= | Influence ranking via power iteration. |
| triangles | GET /graph/:schema/triangles?rel= | Per-node triangle count. Clustering signal. |
| components | GET /graph/:schema/components?rel= | Connected components via Union-Find. |
| louvain | GET /graph/:schema/louvain?rel=&tolerance=&max_levels= | Modularity-greedy community detection. |
| betweenness | GET /graph/:schema/betweenness?rel=&max_nodes= | Brandes' betweenness - bridge identification. |
| eigenvector_centrality | GET /graph/:schema/eigenvector_centrality?rel=&max_iter=&tol= | Influence by who-you're-connected-to. |
| label_propagation | GET /graph/:schema/label_propagation?rel=&max_iter=&seed= | Fast soft community detection. |
| random-walk | GET /graph/:schema/random-walk?rel=&start=&steps=&seed=&p=&q= | Uniform or Node2Vec-biased walks. |
| node2vec | POST /graph/:schema/node2vec { rel, dim, ..., persist } | Train graph embeddings. |
| node2vec/topk | GET /graph/:schema/node2vec/:rel/topk?query=&k=&metric= | Find similar nodes via Node2Vec embeddings. |
| graphsage | POST /graph/:schema/graphsage { rel, dim, ..., feature_col, persist } | Attribute-aware embeddings (Hamilton 2017). |
| graphsage/topk | GET /graph/:schema/graphsage/:rel/topk?query=&k=&metric= | Find similar nodes via GraphSAGE. |
| graphsage/health | GET /graph/:schema/graphsage/:rel/health | Is the persisted embedding set degenerate? Read-only. |
| graphsage/rebuild | POST /graph/:schema/graphsage/:rel/rebuild { feature_col, force } | Retrain a stuck index from the rows already stored. |
Examples.
One hop.
/neighbors is the primitive everything else is built on. All four languages wrap it.
# Who placed this order?
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.orders/neighbors?rel=placed_by&pk=01JTRX9KQ3YH8K2WMX0F5JZAB7" \
-H "Authorization: Bearer $OC_TOKEN"const pks = await db.graph.neighbors("shop.orders", {
rel: "placed_by",
pk: "01JTRX9KQ3YH8K2WMX0F5JZAB7",
});
console.log(pks); // string[] - raw primary keyshits = db.graph.neighbors(
"shop.orders",
rel="placed_by",
pk="01JTRX9KQ3YH8K2WMX0F5JZAB7",
)
for n in hits:
print(n.pk, n.depth) # depth is always 1 herehits, err := db.Graph().Neighbors(ctx, "shop.orders", originchain.NeighborsRequest{
Rel: "placed_by",
PK: "01JTRX9KQ3YH8K2WMX0F5JZAB7",
})
if err != nil { /* handle */ }
for _, n := range hits {
fmt.Println(n.PK, n.Depth) // Depth is always 1 here
}["01JTRX1H4Q9P0N2WMX0F5JZ001"] Note what comes back: primary keys, not rows. The graph endpoints return identity, not content. If you want the customer's name you either fetch the row afterwards, or use Cypher / a plan query, which return full rows.
Going the other way
The relation is declared on shop.orders, and it stays declared there no matter which direction you walk. /reverse is still addressed as graph/shop.orders/…, but the pk you pass is a customer.
# Flip it: which orders did this customer place?
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.orders/reverse?rel=placed_by&pk=01JTRX1H4Q9P0N2WMX0F5JZ001" \
-H "Authorization: Bearer $OC_TOKEN"const pks = await db.graph.reverseNeighbors("shop.orders", {
rel: "placed_by",
pk: "01JTRX1H4Q9P0N2WMX0F5JZ001",
});
console.log(pks.length, "orders for this customer");hits = db.graph.reverse_neighbors(
"shop.orders",
rel="placed_by",
pk="01JTRX1H4Q9P0N2WMX0F5JZ001",
)
print(len(hits), "orders for this customer")hits, err := db.Graph().ReverseNeighbors(ctx, "shop.orders", originchain.NeighborsRequest{
Rel: "placed_by",
PK: "01JTRX1H4Q9P0N2WMX0F5JZ001",
})
if err != nil { /* handle */ }
fmt.Println(len(hits), "orders for this customer") /reverse returns [] - not an error - in three different situations: the node genuinely has no in-edges, the relation was declared bidirectional = false, or you typo'd the rel name. Reverse lookups do not validate that the relation exists. Check your spelling before concluding the graph is empty.
Many hops.
/bfs expands outward from a node and tags every result with its distance. This is the workhorse for "everything within N hops".
# Everyone within 3 referral hops of this customer.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/bfs?rel=referrer&pk=01JTRX1H4Q9P0N2WMX0F5JZ001&max_depth=3" \
-H "Authorization: Bearer $OC_TOKEN"const hits = await db.graph.bfs("shop.customers", {
rel: "referrer",
pk: "01JTRX1H4Q9P0N2WMX0F5JZ001",
max_depth: 3,
});
for (const h of hits) console.log(h.depth, h.pk);hits = db.graph.bfs(
"shop.customers",
rel="referrer",
pk="01JTRX1H4Q9P0N2WMX0F5JZ001",
max_depth=3,
)
for h in hits:
print(h.depth, h.pk)hits, err := db.Graph().BFS(ctx, "shop.customers", originchain.BFSRequest{
Rel: "referrer",
PK: "01JTRX1H4Q9P0N2WMX0F5JZ001",
MaxDepth: 3,
})
if err != nil { /* handle */ }
for _, h := range hits {
fmt.Println(h.Depth, h.PK)
}[
{ "pk": "01JTRX1H4Q9P0N2WMX0F5JZ004", "depth": 1 },
{ "pk": "01JTRX1H4Q9P0N2WMX0F5JZ011", "depth": 2 },
{ "pk": "01JTRX1H4Q9P0N2WMX0F5JZ027", "depth": 3 }
] Reachability and routes
/path answers one question - can I get there - and answers it cheaply.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/path?rel=referrer&src=01JTRX1H4Q9P0N2WMX0F5JZ001&dst=01JTRX1H4Q9P0N2WMX0F5JZ027&max_depth=3" \
-H "Authorization: Bearer $OC_TOKEN"const res = await db.graph.path("shop.customers", {
rel: "referrer",
src: "01JTRX1H4Q9P0N2WMX0F5JZ001",
dst: "01JTRX1H4Q9P0N2WMX0F5JZ027",
max_depth: 3,
});
console.log(res.reachable); // boolean - no node listres = db.graph.path(
"shop.customers",
rel="referrer",
src="01JTRX1H4Q9P0N2WMX0F5JZ001",
dst="01JTRX1H4Q9P0N2WMX0F5JZ027",
max_depth=3,
)
print(res.reachable) # True / False - no node listres, err := db.Graph().Path(ctx, "shop.customers", originchain.PathRequest{
Rel: "referrer",
Src: "01JTRX1H4Q9P0N2WMX0F5JZ001",
Dst: "01JTRX1H4Q9P0N2WMX0F5JZ027",
MaxDepth: 3,
})
if err != nil { /* handle */ }
fmt.Println(res.Reachable) // bool - no node list
The response is { "reachable": true } and nothing else. The route is not materialised. If you need the actual nodes, use /k-shortest with k=1, which does return a node list and a cost.
# Want the actual route, with costs? Use k-shortest.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/k-shortest?rel=referrer&source=01JTRX1H4Q9P0N2WMX0F5JZ001&target=01JTRX1H4Q9P0N2WMX0F5JZ027&k=3" \
-H "Authorization: Bearer $OC_TOKEN" {
"paths": [
{ "nodes": ["...001", "...004", "...027"], "cost": 2.0 },
{ "nodes": ["...001", "...011", "...019", "...027"], "cost": 3.0 }
]
}
Weighting differs between the two weighted endpoints, and it trips people up. /dijkstra takes a weights_json map keyed by "from_pk|to_pk" that you supply in the request - weights are not stored on edges. /k-shortest is usually what you want instead: pass weight_col and it reads the weight from a column on the destination row, defaulting to 1.0 per hop.
Traversal that returns rows: RelationHop.
Underneath the REST endpoints, a hop is a query-plan operator called RelationHop. It reads the forward edge index by prefix and then point-gets each destination row - so unlike /neighbors, it hands back complete rows.
You can post a plan directly to /v1/tenants/:t/query. The plan is plain JSON, tagged by op:
{
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"target": "shop.customers"
} curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/query" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"target": "shop.customers"
}'const res = await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/query`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
op: "relation_hop",
schema: "shop.orders",
rel: "placed_by",
from_pk: ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
target: "shop.customers",
}),
},
);
const rows = await res.json(); // full target rows, not just PKsimport os, requests
BASE = f"https://{os.environ['OC_HOST']}/v1/tenants/{os.environ['OC_TENANT']}"
H = {"Authorization": f"Bearer {os.environ['OC_TOKEN']}"}
rows = requests.post(f"{BASE}/query", headers=H, json={
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"target": "shop.customers",
}).json()
print(rows) # full target rows, not just PKsplan := map[string]any{
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": []string{"01JTRX9KQ3YH8K2WMX0F5JZAB7"},
"target": "shop.customers",
}
body, _ := json.Marshal(plan)
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+"/query",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()
var rows []map[string]any // full target rows, not just PKs
json.NewDecoder(resp.Body).Decode(&rows)Filtering mid-traversal
Chaining hops uses a sibling operator, RelationChain, and this is where plans earn their keep: each hop can carry its own predicate, applied to that hop's output before the next hop expands from it. That is push-down filtering, not post-filtering - a selective predicate early in the chain cuts the work everything after it does.
{
"op": "relation_chain",
"from_schema": "shop.orders",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"hops": [
{ "rel": "placed_by", "target": "shop.customers" },
{
"rel": "referrer",
"target": "shop.customers",
"where_predicate": { "op": "eq", "path": "country", "value": "NG" }
}
]
} Only the final hop's rows come back. And because these are ordinary plan nodes, the usual operators compose around them - filter, project, sort, limit, distinct, aggregate.
RelationChain does not de-duplicate rows between hops and carries no built-in depth cap. On a graph with cycles, A → B → A → … expands combinatorially with every hop you add. Cap the depth yourself, keep chains short, and prefer /bfs - which does track visited nodes - when the shape is "everything within N hops".
Most people should reach for Cypher rather than hand-writing plans - MATCH (o:orders {id: "..."})-[:placed_by]->(c) RETURN c.name compiles to exactly the plan above. Raw plans are there for generated queries and for shapes Cypher does not express.
Graph algorithms.
These are real, callable endpoints - not patterns you assemble yourself. Twenty-two of them, each a single HTTP call against a declared relation. Two worked examples first, then the full catalogue.
PageRank
Ranks influence within a set of nodes you nominate. The nodes parameter is required - PageRank scores a subgraph you define, it does not rank an entire table on its own, and an empty list is a 400.
# PageRank needs an explicit node universe - it does not rank a whole
# table for you. Pass the PKs you want scored.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/pagerank?rel=referrer&nodes=c001,c002,c003,c004,c005&damping=0.85" \
-H "Authorization: Bearer $OC_TOKEN"// The TS SDK wraps neighbors / reverse / bfs / path / dijkstra only.
// The algorithm endpoints are plain GETs.
const qs = new URLSearchParams({
rel: "referrer",
nodes: "c001,c002,c003,c004,c005",
damping: "0.85",
});
const res = await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/graph/shop.customers/pagerank?${qs}`,
{ headers: { "Authorization": `Bearer ${process.env.OC_TOKEN}` } },
);
const hits: { pk: string; score: number }[] = await res.json();
for (const h of hits) console.log(h.pk, h.score);scores = db.graph.pagerank(
"shop.customers",
rel="referrer",
nodes=["c001", "c002", "c003", "c004", "c005"],
damping=0.85,
)
for pk, score in sorted(scores.items(), key=lambda kv: -kv[1]):
print(pk, round(score, 4))// The Go SDK wraps Neighbors / ReverseNeighbors / BFS / Path / Dijkstra
// only. The algorithm endpoints are plain GETs.
q := url.Values{}
q.Set("rel", "referrer")
q.Set("nodes", "c001,c002,c003,c004,c005")
q.Set("damping", "0.85")
req, _ := http.NewRequestWithContext(ctx, "GET",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+
"/graph/shop.customers/pagerank?"+q.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()
var hits []struct {
PK string `json:"pk"`
Score float64 `json:"score"`
}
json.NewDecoder(resp.Body).Decode(&hits)
for _, h := range hits { fmt.Println(h.PK, h.Score) }[
{ "pk": "c001", "score": 0.3120 },
{ "pk": "c004", "score": 0.2455 },
{ "pk": "c002", "score": 0.1810 }
] Louvain communities
Partitions the graph into clusters by modularity. Unlike PageRank it takes the whole relation, and community ids are dense integers starting at zero.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/louvain?rel=referrer" \
-H "Authorization: Bearer $OC_TOKEN"const res = await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/graph/shop.customers/louvain?rel=referrer`,
{ headers: { "Authorization": `Bearer ${process.env.OC_TOKEN}` } },
);
const { communities } = await res.json();
for (const c of communities) console.log(c.community, c.pk);communities = db.graph.louvain("shop.customers", rel="referrer")
# {pk -> community_id}, ids are dense integers from 0
for pk, cid in communities.items():
print(cid, pk)req, _ := http.NewRequestWithContext(ctx, "GET",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+
"/graph/shop.customers/louvain?rel=referrer", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()
var out struct {
Communities []struct {
PK string `json:"pk"`
Community int `json:"community"`
} `json:"communities"`
}
json.NewDecoder(resp.Body).Decode(&out)
for _, c := range out.Communities { fmt.Println(c.Community, c.PK) }{
"communities": [
{ "pk": "c001", "community": 0 },
{ "pk": "c002", "community": 0 },
{ "pk": "c007", "community": 1 }
]
} The full catalogue
All twenty-two live under /v1/tenants/:t/graph/:schema/ and take rel=. Everything is GET except the two embedding trainers and the GraphSAGE rebuild.
| Endpoint | Shape | What it's for |
|---|---|---|
| neighbors | GET ?rel=&pk= | One hop forward. Returns a bare array of primary keys. |
| reverse | GET ?rel=&pk= | One hop backward. Needs bidirectional = true. |
| bfs | GET ?rel=&pk=&max_depth= | Breadth-first frontier with depths. Default depth 3. |
| path | GET ?rel=&src=&dst=&max_depth= | Reachability only - returns { reachable } and no route. |
| dijkstra | GET ?rel=&src=&dst=&weights_json= | Cheapest weighted route. Weights come from the request, not storage. |
| k-shortest | GET ?rel=&source=&target=&k=&weight_col= | Yen's k loop-free routes, with node lists. Max k = 50. |
| all_simple_paths | GET ?rel=&src=&dst=&max_depth=&max_paths= | Every acyclic route between two nodes. Default cap 256 paths. |
| all_simple_paths_bidir | GET ?rel=&src=&dst=&max_depth=&max_paths= | Same, searching from both ends. Needs bidirectional = true. |
| pagerank | GET ?rel=&nodes=&damping=&max_iter=&tol= | Influence ranking by power iteration. nodes= is required. |
| triangles | GET ?rel= | Triangle enumeration, each reported once in canonical order. |
| components | GET ?rel= | Connected components by union-find. Undirected interpretation. |
| betweenness | GET ?rel=&max_nodes= | Brandes' betweenness - finds bridges. Clamped at 100k nodes. |
| eigenvector_centrality | GET ?rel=&max_iter=&tol= | Influence weighted by neighbours' influence. |
| label_propagation | GET ?rel=&max_iter=&seed= | Fast community detection. Pass seed= or results are not reproducible. |
| louvain | GET ?rel=&tolerance=&max_levels= | Modularity-based communities. Up to 500k nodes. |
| random-walk | GET ?rel=&start=&steps=&seed=&p=&q= | Biased random walk sampling. Max 1,000 steps. |
| node2vec | POST { rel, dim, walks_per_node, …, persist } | Train structural embeddings. persist: true enables topk. |
| node2vec/:rel/topk | GET ?query=&k=&metric= | Nearest nodes by persisted Node2Vec embedding. |
| graphsage | POST { rel, dim, layers, feature_col, persist } | Attribute-aware embeddings that read a feature column. |
| graphsage/:rel/topk | GET ?query=&k=&metric= | Nearest nodes by persisted GraphSAGE embedding. |
| graphsage/:rel/health | GET | Health of a persisted GraphSAGE index. Read-only, no row data. |
| graphsage/:rel/rebuild | POST { feature_col, force, dry_run } | Retrain a degenerate index in place. 404 if none, 409 if healthy. |
Named here so you don't go looking:
- Strongly connected components (Tarjan / Kosaraju) - components is undirected only
- Topological sort
- Minimum spanning tree
- Clustering coefficient - triangles gives you the raw counts to compute it yourself
SDK coverage is uneven. TypeScript and Go wrap the five traversal endpoints - neighbors, reverse, bfs, path, dijkstra - and nothing else. Python additionally wraps k-shortest, shortest-path, random-walk, PageRank, Louvain, label propagation, betweenness, and the Node2Vec / GraphSAGE top-k calls. Everything else is a plain GET, as the tabs above show.
Limits and gotchas.
- Graph is a capability you enable, not one you buy - it is included on every paid configuration at no extra charge. Every
/graph/*route returns402with{"addon":"graph"}if the capability is not enabled on your instance. Traversal via Cypher goes through a different route and a different check. - Some caps clamp, some reject.
kabove 50 on k-shortest is a400- deliberately, so you budget rather than silently getting fewer paths. Betweenness above its node ceiling clamps instead. Know which one you are relying on. - Ceilings worth writing down: variable-length depth 64, k-shortest k 50, random walk 1,000 steps, betweenness 100,000 nodes, Louvain 500,000 nodes, Node2Vec and GraphSAGE 100,000 nodes and 1,024 dimensions, and an 8 MiB HTTP body cap.
- Label propagation is non-deterministic unless you pass
seed. Without one the server seeds from the clock, and the seed is not echoed back - so you cannot reproduce a run after the fact. Always pass your own. - Connected components is undirected. It unions both endpoints of every edge, so it will not give you strongly connected components on a directed graph.
- Graph calls are admission-controlled. They are classed as heavy operations and can return
429with aRetry-Afterunder memory pressure, or413when a result exceeds the size budget. Both are protective - retry or narrow the query rather than looping hard.
6. Examples.
6.1 MATCH and RETURN.
The simplest query pins one node and projects some properties off it. A label - :orders - is matched case-insensitively against registered table names, and default_schema is a full namespace.table id that applies only to patterns carrying no label.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRX9KQ3YH8K2WMX0F5JZAB7\"}) RETURN o.status, o.amount_cents",
"default_schema": "shop.orders"
}'// No SDK wraps /cypher yet - all four tabs call the route directly.
const BASE = `https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}`;
const H = {
"Authorization": `Bearer ${process.env.OC_TOKEN}`,
"Content-Type": "application/json",
};
async function cypher(query: string, defaultSchema?: string, params = {}) {
const res = await fetch(`${BASE}/cypher`, {
method: "POST",
headers: H,
body: JSON.stringify({
cypher: query,
default_schema: defaultSchema,
params,
}),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
const out = await cypher(
`MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})
RETURN o.status, o.amount_cents`,
"shop.orders",
);
console.log(out.kind, out.rows);# No SDK wraps /cypher yet - all four tabs call the route directly.
import os, requests
BASE = f"https://{os.environ['OC_HOST']}/v1/tenants/{os.environ['OC_TENANT']}"
H = {"Authorization": f"Bearer {os.environ['OC_TOKEN']}"}
def cypher(query, default_schema=None, params=None):
r = requests.post(
f"{BASE}/cypher",
headers=H,
json={
"cypher": query,
"default_schema": default_schema,
"params": params or {},
},
)
r.raise_for_status()
return r.json()
out = cypher(
'MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"}) '
'RETURN o.status, o.amount_cents',
default_schema="shop.orders",
)
print(out["kind"], out["rows"])// No SDK wraps /cypher yet - all four tabs call the route directly.
type cypherReq struct {
Cypher string `json:"cypher"`
DefaultSchema string `json:"default_schema,omitempty"`
Params map[string]any `json:"params,omitempty"`
}
func cypher(ctx context.Context, req cypherReq) (map[string]any, error) {
body, _ := json.Marshal(req)
hreq, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+"/cypher",
bytes.NewReader(body))
hreq.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
hreq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(hreq)
if err != nil { return nil, err }
defer resp.Body.Close()
var out map[string]any
err = json.NewDecoder(resp.Body).Decode(&out)
return out, err
}
out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})
RETURN o.status, o.amount_cents`,
DefaultSchema: "shop.orders",
})
if err != nil { /* handle */ }
fmt.Println(out["kind"], out["rows"]){
"kind": "select",
"rows": [
{ "status": "paid", "amount_cents": 12950 }
]
} RETURN o parses, runs, returns 200, and hands back {} for every row. Whole-node return is not implemented - the projection looks for a column literally named o and finds none. Always name the properties you want. The exceptions are variables bound by WITH … AS n or UNWIND … AS n, which do carry values.
Note the column names in that response. o.status came back as status - the variable prefix is dropped, and the rest of the dotted path becomes the key. Use AS when you want to control it.
6.2 Filtering.
WHERE supports =, <> (and !=), <, <=, >, >=, AND, OR, NOT, and IS NULL / IS NOT NULL. Each comparison puts a property on one side and a literal on the other.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders) WHERE o.status = \"paid\" AND o.amount_cents > 5000 RETURN o.id, o.amount_cents",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders)
WHERE o.status = "paid" AND o.amount_cents > 5000
RETURN o.id, o.amount_cents`,
"shop.orders",
);out = cypher(
'MATCH (o:orders) '
'WHERE o.status = "paid" AND o.amount_cents > 5000 '
'RETURN o.id, o.amount_cents',
default_schema="shop.orders",
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders)
WHERE o.status = "paid" AND o.amount_cents > 5000
RETURN o.id, o.amount_cents`,
DefaultSchema: "shop.orders",
})
Three limits to internalise now, because the error messages for them are generic. There is no IN, no STARTS WITH / CONTAINS / ENDS WITH, and no regex - those all surface as cypher parse: trailing tokens after query, which reads like a syntax slip rather than a missing feature. And you cannot compare two nodes to each other: WHERE o.customer = c.id is refused.
6.3 Walking one hop.
-[:placed_by]-> names the relation declared on shop.orders. The engine reads the pre-built edge index rather than scanning the target table, so a hop costs a prefix lookup plus one point-get per neighbour.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRX9KQ3YH8K2WMX0F5JZAB7\"})-[:placed_by]->(c) RETURN c.name, c.country",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c)
RETURN c.name, c.country`,
"shop.orders",
);out = cypher(
'MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c) '
'RETURN c.name, c.country',
default_schema="shop.orders",
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c)
RETURN c.name, c.country`,
DefaultSchema: "shop.orders",
}){
"kind": "select",
"rows": [
{ "name": "Ada Okafor", "country": "NG" }
]
} Backwards, and both ways
<-[:rel]- walks the edge in reverse, and -[:rel]- walks it in both. Reverse traversal only works when the relation declares bidirectional = true - which is the default, but a relation explicitly set to false silently returns nothing rather than erroring.
# Which orders did this customer place? Walk the edge backwards.
# Only legal because placed_by declares bidirectional = true.
MATCH (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})<-[:placed_by]-(o)
RETURN o.id, o.amount_cents
The first node of any pattern that contains a hop must pin its primary key in the property map. MATCH (o:orders)-[:placed_by]->(c) is a hard error, not a slow full-graph query - the message asks you to add {id: ...}. A bare MATCH (o:orders) with no hop is fine and scans.
6.4 Walking several hops.
Chain arrows to cross more than one relation in a single pattern. Each hop names its own relation, and only the last hop's nodes come back as rows.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRX9KQ3YH8K2WMX0F5JZAB7\"})-[:placed_by]->(c)-[:referrer]->(r) RETURN r.name, r.country",
"default_schema": "shop.orders"
}'// order -> customer -> the customer who referred them
const out = await cypher(
`MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c)-[:referrer]->(r)
RETURN r.name, r.country`,
"shop.orders",
);# order -> customer -> the customer who referred them
out = cypher(
'MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})'
'-[:placed_by]->(c)-[:referrer]->(r) '
'RETURN r.name, r.country',
default_schema="shop.orders",
)// order -> customer -> the customer who referred them
out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})
-[:placed_by]->(c)-[:referrer]->(r)
RETURN r.name, r.country`,
DefaultSchema: "shop.orders",
})Variable-length paths
When you don't know the depth in advance, -[:rel*1..3]-> walks between one and three hops and returns everything it reaches. Bind the path with p = to use length(p), nodes(p) and relationships(p).
This only works on a self-recursive relation - one whose target table declares a relation of the same name, like referrer on shop.customers. You cannot walk a variable number of hops across placed_by, because orders do not point at orders.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH p = (c:customers {id: \"01JTRX1H4Q9P0N2WMX0F5JZ001\"})-[:referrer*1..3]->(up) RETURN up.name, length(p)",
"default_schema": "shop.customers"
}'// Everyone up to 3 referral hops above this customer.
const out = await cypher(
`MATCH p = (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})-[:referrer*1..3]->(up)
RETURN up.name, length(p)`,
"shop.customers",
);# Everyone up to 3 referral hops above this customer.
out = cypher(
'MATCH p = (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})'
'-[:referrer*1..3]->(up) '
'RETURN up.name, length(p)',
default_schema="shop.customers",
)// Everyone up to 3 referral hops above this customer.
out, err := cypher(ctx, cypherReq{
Cypher: `MATCH p = (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})
-[:referrer*1..3]->(up)
RETURN up.name, length(p)`,
DefaultSchema: "shop.customers",
}){
"kind": "select",
"rows": [
{ "name": "Ravi Menon", "length(p)": 1 },
{ "name": "Sofia Duarte","length(p)": 2 }
]
}
A bare * means *1..64; 64 is the hard depth ceiling. Two shapes are refused inside a longer chain: a variable-length hop cannot be one link of a multi-hop pattern, and a chain may contain at most three undirected hops (each one doubles the work). shortestPath(…) is available over a variable-length pattern when you only want the shortest route between two pinned nodes.
6.5 Ordering and limiting.
ORDER BY, SKIP and LIMIT attach to RETURN and nowhere else - a WITH … ORDER BY in the middle of a query does not parse. Sort keys must be a property access or a bare variable; ASC is the default.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders) WHERE o.status = \"paid\" RETURN o.id AS order_id, o.amount_cents AS cents ORDER BY o.amount_cents DESC SKIP 0 LIMIT 10",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders) WHERE o.status = "paid"
RETURN o.id AS order_id, o.amount_cents AS cents
ORDER BY o.amount_cents DESC
SKIP 0 LIMIT 10`,
"shop.orders",
);out = cypher(
'MATCH (o:orders) WHERE o.status = "paid" '
'RETURN o.id AS order_id, o.amount_cents AS cents '
'ORDER BY o.amount_cents DESC '
'SKIP 0 LIMIT 10',
default_schema="shop.orders",
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders) WHERE o.status = "paid"
RETURN o.id AS order_id, o.amount_cents AS cents
ORDER BY o.amount_cents DESC
SKIP 0 LIMIT 10`,
DefaultSchema: "shop.orders",
}) SKIP and LIMIT take integer literals. A parameter there does not parse, so build the number into the query string when you paginate.
6.6 Parameters.
$name placeholders are substituted from the request's params map before the query is planned. Values must be scalars - string, number, boolean or null; arrays and objects are rejected. Referencing a parameter you didn't supply is a 400, not a silent null.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders) WHERE o.status = $want RETURN o.id",
"default_schema": "shop.orders",
"params": { "want": "paid" }
}'const out = await cypher(
`MATCH (o:orders) WHERE o.status = $want RETURN o.id`,
"shop.orders",
{ want: "paid" },
);out = cypher(
"MATCH (o:orders) WHERE o.status = $want RETURN o.id",
default_schema="shop.orders",
params={"want": "paid"},
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders) WHERE o.status = $want RETURN o.id`,
DefaultSchema: "shop.orders",
Params: map[string]any{"want": "paid"},
})
Parameters work in WHERE, RETURN and UNWIND. They do not work inside a pattern's property map, so the very place you most want one - (o {id: $id}) - is refused, and the anchor id has to be interpolated into the query text. Escape it yourself.
6.7 Writing through Cypher.
All four write verbs are implemented and go through the same route. Each returns its own response shape rather than rows.
# CREATE - insert one node. Returns {"kind":"insert","rows_inserted":1}
CREATE (o:orders {
id: "01JTRXNEW00000000000000001",
customer: "01JTRX1H4Q9P0N2WMX0F5JZ001",
amount_cents: 4200,
status: "pending",
notes: "gift wrap",
placed_ms: 1714480000000
})# MERGE - insert only if absent. Returns {"kind":"merge","rows_inserted":0|1}
MERGE (c:customers {
id: "01JTRX1H4Q9P0N2WMX0F5JZ009",
name: "Nia Blake",
country: "GB"
})# SET - update properties on an anchored node.
# Returns {"kind":"update","rows_affected":1}
MATCH (o:orders {id: "01JTRXNEW00000000000000001"})
SET o.status = "paid"# DELETE - retire an anchored node and its derived index/edge state.
# Returns {"kind":"delete","rows_deleted":1}
MATCH (o:orders {id: "01JTRXNEW00000000000000001"})
DELETE oSent over the wire, an update looks like any other Cypher call:
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRXNEW00000000000000001\"}) SET o.status = \"paid\"",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders {id: "01JTRXNEW00000000000000001"}) SET o.status = "paid"`,
"shop.orders",
);out = cypher(
'MATCH (o:orders {id: "01JTRXNEW00000000000000001"}) SET o.status = "paid"',
default_schema="shop.orders",
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRXNEW00000000000000001"}) SET o.status = "paid"`,
DefaultSchema: "shop.orders",
})map[kind:update rows_affected:1]{"kind": "update", "rows_affected": 1}{ kind: "update", rows_affected: 1 } SET and DELETE both require a preceding MATCH that pins a primary key. An unanchored mutation would be a whole-table rewrite, so it is refused outright. A trailing RETURN after a write parses but is ignored - the write's own counter is what you get back. A constraint violation returns 409 with {"error":"constraint_violation","detail":"…"}.
Result shapes.
Every response carries a kind discriminator; branch on it. There is no columns array and no data envelope - reads hand back plain JSON objects keyed by the projection's output names.
| kind | Body | Emitted by |
|---|---|---|
| select | rows: [ … ] | Any query ending in RETURN |
| insert | rows_inserted: n | CREATE, and FOREACH bodies |
| merge | rows_inserted: n | MERGE - counts only rows that were absent |
| update | rows_affected: n | SET |
| delete | rows_deleted: n | DELETE |
Every response also carries an X-OC-Query-Id header - log it, it is what support will ask for.
Every clause that works.
| Clause | Notes |
|---|---|
| MATCH | Node patterns, relationship patterns, chained hops, and comma-separated patterns. |
| OPTIONAL MATCH | Left-outer semantics. Cannot be the first clause of a query - put a MATCH before it. |
| WHERE | After MATCH, OPTIONAL MATCH or WITH. Property-vs-literal comparisons only. |
| RETURN / RETURN DISTINCT | Projections and aliases. See the RETURN n warning above. |
| ORDER BY / SKIP / LIMIT | Only inside RETURN. SKIP and LIMIT take non-negative integer literals, not parameters. |
| WITH | Projection between stages. WITH DISTINCT and aggregates inside WITH are both refused. |
| UNWIND | Expands a literal list, or a list column on a matched row, into rows. |
| CREATE | Inserts a node. Can also set a relationship column when it follows a MATCH. |
| MERGE | Insert-if-absent on a node pattern. Existing rows are left untouched. |
| SET | var.prop = literal, on a node anchored by primary key. |
| DELETE | One variable, anchored by primary key. Removes derived index and edge state too. |
| FOREACH | Write-only bodies: CREATE, SET, DELETE, or a nested FOREACH. |
| CALL { … } YIELD | Subquery form only, read-only, no nesting. |
| shortestPath(…) | Over a variable-length pattern between two primary-key-anchored nodes. |
Aggregates - count(*), count(x), sum, avg, min, max - work, but only on their own: there is no GROUP BY, so an aggregate cannot share a RETURN with a plain column. The scalar functions upper, lower, length, coalesce and abs are also available.
Every clause that does not.
This is the list people arrive expecting. Most of these fail with a generic parse error rather than a helpful one, so check here before assuming you have a syntax bug.
| Not supported | What to do instead |
|---|---|
| UNION | Refused with a pointer to SQL UNION on /sql, or merge client-side. |
| REMOVE | Refused. Use SQL UPDATE ... SET col = NULL instead. |
| DETACH DELETE | Parses, then refused. Plain DELETE already clears derived edge state. |
| IN | No such operator. Write it as OR-ed equalities. |
| STARTS WITH / CONTAINS / ENDS WITH | No string predicates at all. Use full-text search or SQL LIKE. |
| =~ (regex) | The ~ character does not even lex - you get a lex error, not a parse error. |
| CASE | No conditional expressions. Do it in SQL or in your application. |
| EXISTS(…) | Not a recognised function. |
| collect() | Not implemented. count / sum / avg / min / max are. |
| count(DISTINCT x) | DISTINCT inside an aggregate is rejected. RETURN DISTINCT works. |
| RETURN * | Not an expression. List the properties you want. |
| Arithmetic in WHERE or RETURN | o.amount_cents * 2 is refused; the error points you at /sql. |
| Aggregates mixed with plain columns | There is no GROUP BY in this dialect. Aggregate alone, or use SQL. |
| Relationship variables -[r:TYPE]-> | The parser demands a colon straight after the bracket. Edges cannot be bound. |
| Untyped edges: --> or -[]-> | Every hop must name a declared relation. |
| Edge property maps / type alternation | -[:R {since: 2020}]-> and -[:A|:B]-> both fail to parse. |
| Multi-label nodes (n:A:B) | One label per node pattern. |
| Parameters inside pattern maps | (o {id: $x}) is refused - a property map takes literals. $params work in WHERE, RETURN and UNWIND. |
| allShortestPaths(…) | Only the singular shortestPath() exists. |
| CALL db.labels() and other procedures | Only the CALL { subquery } form is implemented. |
| Cross-variable comparison a.x = b.y | WHERE compares a property against a literal, not against another node. |
Limits and gotchas.
- Composite primary keys cannot be used. Every anchored pattern resolves through a single primary-key column. A table with a two-column key is not reachable from Cypher.
- Labels resolve to table names, first match wins. If two namespaces both hold a table called
orders, a bare:orderslabel is ambiguous, anddefault_schemadoes not disambiguate it - the resolver never consults it once a label is present. - Two MATCH clauses need a WITH between them. Back-to-back MATCHes are refused; project through
WITHto chain stages. - Comma-separated patterns are a separate, stricter mode.
MATCH (a)-[:r]->(b), (b)-[:r]->(c) RETURN a, b, cruns a dedicated pattern-matching join, and it accepts only that exact shape: no other clauses, no variable-length, no undirected hops, every node needs a variable, RETURN takes bare node variables only, and WHERE may contain nothing but AND-eda <> bdistinctness checks. - A cyclic graph can fan out. Multi-hop chains do not de-duplicate visited rows, so a cycle expands combinatorially with each hop. Keep chains short and prefer a bounded variable-length range.
- Large results are refused, not truncated. Exceeding the result-row or result-byte budget returns
413with the observed size and the cap. Add aLIMIT. - Under load you may get
429. Cypher counts as a heavy operation and is admission-controlled. HonourRetry-After. - The request body cap is 8 MiB, which matters if you generate long
UNWINDliteral lists. - Cypher calls are authorised by what they actually do - a pure read is checked like any other SELECT, and each write verb requires the matching privilege on its target schema. A read-only token can run a read-only Cypher query.