Fabriq

Graph

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.

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:

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.

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.

MATCH (a:Asset)-[:LOCATED_AT]->(s:Site {id: $site})
RETURN a.id
ORDER BY a.id
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:

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.

MATCH (a:Asset)-[:CHILD_OF*1..3]->(root:Asset {id: $root})
RETURN a.id
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.

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

On this page