OriginChain docs
schema · cypher

Cypher.

Cypher reads the same rows SQL does. What it adds is a pattern grammar: instead of joining tables on keys, you draw the shape you are looking for - (a)-[:rel]->(b) - and the engine walks it.

Reach for it when the question is "what is this row connected to, and what is that connected to". A three-hop question is three arrows in Cypher and three joins in SQL, and the arrows stay readable. For aggregation, string matching, arithmetic or anything with a GROUP BY in it, use SQL - this dialect deliberately does not compete there.

one route

POST /v1/tenants/:t/cypher with a body of { cypher, default_schema?, params? }. Reads and writes both go here. No SDK wraps this route yet, so every tab on this page builds the request by hand - which is three lines of boilerplate and then plain query strings.

Before you start: the graph shape.

A relationship is not a table. It is a column on the row that holds another row's primary key, plus a [[relations]] block naming it. Declare the block and every write to that table starts maintaining the edge for you. Graph walks through the modelling in depth; here is the shape these examples use.

Register shop.customers first - a relation's target table must already exist when the pointing table is registered. It refers to itself through referred_by, which is what makes a variable-length referral chain legal later on.

schemas/customers.toml
namespace   = "shop"
table       = "customers"
primary_key = ["id"]

[[columns]]
name = "id"
ty   = "str"
required = true

[[columns]]
name = "name"
ty   = "str"

[[columns]]
name = "country"
ty   = "str"

[[columns]]
name = "referred_by"
ty   = "str"        # another customer's id - or absent

# A customer points at the customer who referred them. Source and target
# are the SAME table, which is what makes variable-length paths legal.
[[relations]]
name          = "referrer"
from_col      = "referred_by"
target        = { namespace = "shop", table = "customers", pk = "id" }
bidirectional = true

# REQUIRED for MATCH (c:customers {id: '...'}) to resolve.
[[indexes]]
name    = "by_id"
columns = ["id"]
schemas/orders.toml
namespace   = "shop"
table       = "orders"
primary_key = ["id"]

[[columns]]
name = "id"
ty   = "str"
required = true

[[columns]]
name = "customer"
ty   = "str"        # a shop.customers id

[[columns]]
name = "amount_cents"
ty   = "i64"

[[columns]]
name = "status"
ty   = "str"

[[columns]]
name = "notes"
ty   = "str"

[[columns]]
name = "placed_ms"
ty   = "u64"

# The edge: an order points at the customer who placed it.
[[relations]]
name          = "placed_by"
from_col      = "customer"
target        = { namespace = "shop", table = "customers", pk = "id" }
bidirectional = true

[[indexes]]
name    = "by_id"
columns = ["id"]
the by_id index is not optional

MATCH (o:orders {id: "..."}) resolves through an index named by_<primary key column>. Without that [[indexes]] block the point match does not resolve, and since every traversal has to start from a pinned node, nothing on this page will work. Declare it on any table you intend to query with Cypher.

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 supplies the namespace, so pass both.

POST /v1/tenants/:t/cypher
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"
  }'
response
{
  "kind": "select",
  "rows": [
    { "status": "paid", "amount_cents": 12950 }
  ]
}
RETURN n gives you an empty object

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.

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.

WHERE with AND
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"
  }'

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.

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.

one-hop traversal
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"
  }'
response
{
  "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
every traversal needs an anchor

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.

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.

chained hops across two relations
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"
  }'

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.

variable-length path, 1 to 3 hops
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"
  }'
response
{
  "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.

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.

ORDER BY … SKIP … LIMIT
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"
  }'

SKIP and LIMIT take integer literals. A parameter there does not parse, so build the number into the query string when you paginate.

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.

named parameters
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" }
  }'

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.

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.

the four write verbs
# 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
})

Sent over the wire, an update looks like any other Cypher call:

SET over HTTP
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"
  }'

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
selectrows: [ … ]Any query ending in RETURN
insertrows_inserted: nCREATE, and FOREACH bodies
mergerows_inserted: nMERGE - counts only rows that were absent
updaterows_affected: nSET
deleterows_deleted: nDELETE

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 below.
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 :orders label is ambiguous. Always send default_schema.
  • Two MATCH clauses need a WITH between them. Back-to-back MATCHes are refused; project through WITH to chain stages.
  • Comma-separated patterns are a separate, stricter mode. MATCH (a)-[:r]->(b), (b)-[:r]->(c) RETURN a, b, c runs 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-ed a <> b distinctness 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 413 with the observed size and the cap. Add a LIMIT.
  • Under load you may get 429. Cypher counts as a heavy operation and is admission-controlled. Honour Retry-After.
  • The request body cap is 8 MiB, which matters if you generate long UNWIND literal lists.
  • Every Cypher call is treated as a write for authorisation, including pure reads. A read-only token cannot call this route.

Related.