Schema for Cypher.
Cypher reads the same rows as SQL. The difference is the pattern grammar: MATCH (a)-[:rel]->(b) walks a graph edge declared in [[relations]]. Without that block, only single-node MATCH works.
Engine surface: POST /v1/tenants/:t/cypher with { "cypher": "...", "default_schema": "..." } body. Translator: pragmatic Cypher subset → oc_query::Plan tree → executor (same plan cache as SQL).
Required schema fields.
Without these, this query surface doesn't function at all.
| field | effect |
|---|---|
| namespace + table + primary_key + [[columns]] | Everything SQL needs — Cypher reads the same rows. |
| [[relations]] (only for traversal patterns) | Required for any pattern that walks an edge like (a)-[:rel]->(b). Without it, only single-node MATCH (n) WHERE … works. |
Optional fields — what each one unlocks.
Add only the fields whose effect you need. Each one buys a specific capability — speed up a predicate, guard a write, or unlock a new query shape.
| field | type | default | effect |
|---|---|---|---|
| [[relations]] bidirectional | bool | true | Reverse edge written atomically so MATCH (p)<-[:r]-(o) reads via the same key. Default true. |
| default_schema (body field) | string | — | Body field on the /cypher request — anchors unanchored MATCH (n) WHERE … to a specific schema when the pattern doesn't pin one. |
| [[relations]].target.{namespace,table,pk} | sub-table | — | Target descriptor on each relation. Required, but goes inside the relation — engine resolves target rows by walking the back-edge key into this PK. |
What's in the subset.
- MATCH (n) WHERE n.col = literal — single-node
- MATCH (n {pk_col: literal}) — node anchored by PK
- MATCH (a)-[:rel]->(b) — single-hop traversal (forward)
- MATCH (a)<-[:rel]-(b) — single-hop traversal (reverse, if bidirectional=true)
- RETURN projections — bare columns, comma-separated, multi-column
- CREATE (n {props…}) — single-node insert
- UNWIND list AS var RETURN var
Refused shapes.
The engine returns a typed 400 with a hint instead of running these. Knowing them up front avoids a debugging round-trip.
| shape | why |
|---|---|
| Labels in patterns: (o:orders {...}) | Schema id comes from default_schema or pk match — labels would be a second mechanism. |
| Multi-hop / variable-length: -[:r*1..3]-> | Use the BFS / path / k-shortest graph endpoints instead. |
| MERGE | Last-writer-wins puts; idempotent CREATE + match works for the same intent. |
| OPTIONAL MATCH | v1 punt — rewrite as two queries client-side. |
| WITH clauses, CALL subqueries | v1 punt — Plan tree composition handles the common cases. |
| Bound parameters ($name) | Inline literals only — prepared statements are v1. |
| Comma-separated MATCH (multi-pattern) | v1 punt — one pattern per query. |
Abbreviation legend.
| token | meaning |
|---|---|
| PK | Primary key — column(s) listed in primary_key = [...] |
| [[relations]] | Schema TOML block declaring a graph edge between two tables |
| from_col | The column on THIS table whose value is the relation's source — NOT 'column' |
| [relations.target] | Sub-table inside [[relations]] giving the target's namespace, table, and pk column |
| bidirectional | When true (default), engine writes a reverse edge atomically so traversal works in both directions |
| default_schema | Body field on /cypher requests — anchors unanchored MATCH to a schema id |
| v0 / v1 | Engine API generations — v0 today, v1 next release |
Worked example.
Schema TOML — copy + register via POST /v1/tenants/:t/schemas with Content-Type: text/plain.
namespace = "shop"
table = "orders"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "customer_id"
ty = "str"
required = true
[[columns]]
name = "product_id"
ty = "str"
required = true
[[columns]]
name = "qty"
ty = "i64"
# Graph edge orders → products
[[relations]]
name = "bought_product"
from_col = "product_id"
bidirectional = true
[relations.target]
namespace = "shop"
table = "products"
pk = "id"
# Graph edge orders → customers
[[relations]]
name = "by_customer"
from_col = "customer_id"
bidirectional = true
[relations.target]
namespace = "shop"
table = "customers"
pk = "id" Queries it enables.
# MATCH + WHERE
curl -X POST $BASE/v1/tenants/$T/cypher -H "Authorization: Bearer $BEARER" \
-d '{
"cypher": "MATCH (o) WHERE o.customer_id = \"c001\" RETURN o.id, o.product_id, o.qty",
"default_schema": "shop.orders"
}'
# Single-hop traversal (forward)
curl -X POST $BASE/v1/tenants/$T/cypher -H "Authorization: Bearer $BEARER" \
-d '{
"cypher": "MATCH (o {id: \"o001\"})-[:bought_product]->(p) RETURN p.name, p.price_cents",
"default_schema": "shop.orders"
}'
# Reverse traversal (works because bidirectional=true)
curl -X POST $BASE/v1/tenants/$T/cypher -H "Authorization: Bearer $BEARER" \
-d '{
"cypher": "MATCH (o {id: \"o001\"})-[:by_customer]->(c) RETURN c.name, c.country",
"default_schema": "shop.orders"
}'
# CREATE (single-node insert; NO label syntax in v0)
curl -X POST $BASE/v1/tenants/$T/cypher -H "Authorization: Bearer $BEARER" \
-d '{
"cypher": "CREATE (o {id: \"o-new-1\", customer_id: \"c001\", product_id: \"p001\", qty: 1, total_cents: 14999, ts: 1718200000})",
"default_schema": "shop.orders"
}'
# UNWIND
curl -X POST $BASE/v1/tenants/$T/cypher -H "Authorization: Bearer $BEARER" \
-d '{"cypher": "UNWIND [1,2,3,4,5] AS n RETURN n"}'