---
title: Graph
description: The read-only openCypher graph port — Query, TraverseAndHydrate (one batched hydration, never N+1), and the conformance suite that gates engine swaps.
---

The graph plane is a knowledge-graph projection of aggregate relationships, derived from each entity's foreign keys. You query it with read-only openCypher; you do not write to it directly — projection consumers and rebuilds apply engine-neutral mutations. `query.GraphQuerier` is implemented in `adapters/falkordb` against FalkorDB, which speaks the `GRAPH.QUERY` / `GRAPH.RO_QUERY` RESP commands.

```go
type GraphQuerier interface {
	Query(ctx context.Context, cypher string, params map[string]any, into any) error
	TraverseAndHydrate(ctx context.Context, cypher string, params map[string]any, into any) error
	ApplyMutations(ctx context.Context, target string, muts []projection.Mutation) error
}
```

Reach the port through `f.Graph()`. Each tenant has its own graph (`tenant_{id}` in FalkorDB), so traversals are isolated structurally; reads resolve the tenant's live graph through an injected resolver, which lets blue-green rebuilds flip readers atomically.

## What gets projected

The registry derives the graph shape — no Cypher in your schema. `EntitySpec.GraphNode` is the node label for an entity (empty means it is not projected), and each `EdgeSpec` maps a foreign-key column to a relationship:

```go
type EdgeSpec struct {
	Field  string // FK column on this entity's table
	Rel    string // relationship type, e.g. "LOCATED_AT"
	Target string // registry name of the target entity
}
```

So an `asset` with `GraphNode: "Asset"` and an edge `{Field: "site_id", Rel: "LOCATED_AT", Target: "site"}` projects `(:Asset)-[:LOCATED_AT]->(:Site)` whenever the FK is set, and detaches it when the FK clears. See [Registry](/docs/fabriq/(concepts)/registry).

## Query

`Query` runs a read-only openCypher query against the tenant's live graph. The `into` target may be `*[]string` for single-column id traversals, or `*[]map[string]any` for column-keyed rows. Scanning multiple columns into `*[]string` is an error.

```cypher
MATCH (a:Asset)-[:LOCATED_AT]->(s:Site {id: $site})
RETURN a.id
ORDER BY a.id
```

```go
var ids []string
err := f.Graph().Query(ctx,
	`MATCH (a:Asset)-[:LOCATED_AT]->(s:Site {id: $site}) RETURN a.id ORDER BY a.id`,
	map[string]any{"site": siteID},
	&ids)
```

For multi-column rows, scan into `*[]map[string]any` keyed by the `RETURN` column names:

```go
var rows []map[string]any
err := f.Graph().Query(ctx,
	`MATCH (a:Asset)-[:CHILD_OF]->(p:Asset) RETURN a.id AS child, p.id AS parent`,
	nil, &rows)
// rows[i]["child"], rows[i]["parent"]
```

## TraverseAndHydrate

`TraverseAndHydrate` is the composed graph→relational read: the Cypher traversal must `RETURN` a single column of aggregate ids, and Fabriq then hydrates the full rows from Postgres in exactly ONE batched relational query (the same `GetMany` primitive). Never N+1. The target entity is inferred from `into`'s element type via the registry, so you pass a typed slice and never name the entity.

```cypher
MATCH (a:Asset)-[:CHILD_OF*1..3]->(root:Asset {id: $root})
RETURN a.id
```

```go
var descendants []domain.Asset
err := f.Graph().TraverseAndHydrate(ctx,
	`MATCH (a:Asset)-[:CHILD_OF*1..3]->(root:Asset {id: $root}) RETURN a.id`,
	map[string]any{"root": rootID},
	&descendants)
// Step 1: graph returns ids. Step 2: one Postgres GetMany hydrates the rows.
```

`into` must be a pointer to a slice of registered models (pointer or value elements); an empty traversal result is a no-op, not an error.

## ApplyMutations (projection write path)

`ApplyMutations` applies engine-neutral `projection.Mutation` values (`NodeUpsert`, `EdgeUpsert`, `NodeDelete`, `EdgeDelete`) to a named target graph. This is for projection consumers and rebuilds only — application code reads, it never writes the graph. A target of `""` resolves to the tenant's live graph (from the event's tenant on ctx); a non-empty target names a blue-green rebuild graph. Version gating in the dialect makes replays idempotent: a mutation whose version is `<=` the stored node version is a no-op.

## openCypher common subset and the conformance gate

Shipped Cypher must stay inside the **openCypher common subset** — the portable intersection that FalkorDB, Memgraph, Neo4j, and Kùzu all honor. The gate is `adapters/graphtest`, an exported conformance suite every `GraphQuerier` must pass before Fabriq will project into it. Its canonical cases cover property match, single-hop traversal with ordering, variable-length paths (`*1..3`), `WHERE` comparisons, detach-delete, and stale-version no-ops; engines must pass every case verbatim, with no dialect-specific rewrites. That suite is the engine-swap contract — pass it and you can swap the backing engine without touching appliers or call sites.

```go
func TestFalkorConformance(t *testing.T) {
	graphtest.Run(t, func(t *testing.T) graphtest.Harness {
		// boot a container, return the adapter + a fresh target graph
	})
}
```

<Cards>
  <Card title="Relational" href="/docs/fabriq/(data-planes)/relational">GetMany — the one-query hydration primitive TraverseAndHydrate builds on.</Card>
  <Card title="Registry" href="/docs/fabriq/(concepts)/registry">GraphNode labels and EdgeSpec — how FK columns become relationships.</Card>
</Cards>
