Skip to content

8. Views — a saved query used as a label

A view is a named, saved Cypher body whose rows are a type, referenced in a later query by its name as a plain label. It is the natural extension of a virtual join: a virtual label is a view over an external system; a view is a view over the graph (real + virtual) itself. Two kinds, by cost:

  • Regular (inlined). Expanded at query time — the view’s MATCH … RETURN <var> body is spliced in and its return variable renamed to the outer alias, so MATCH (c:my_key_accounts)-[:R]->(x) becomes MATCH <view body> MATCH (c)-[:R]->(x). Always fresh; no storage. Inlining is flat, not a CALL {} subquery: the view’s members bind the outer alias directly, so the rest of the query traverses from them as from any matched node. (Unit CALL {} subqueries are allowed in queries — §4, §16 — a view reference is simply not expanded into one.)
  • Materialized (cached). The result nodes are committed and a reference reads the cache instead of re-running the body, until a TTL / refresh policy invalidates it. This is the durable sibling of the ephemeral keep(queryId) option: the expensive fan-out (map) + agentic reduce runs on a refresh schedule, in the background under a big budget; the interactive query just reads the precomputed result.

8.1 No new grammar — usage is a label, definition is metadata

Section titled “8.1 No new grammar — usage is a label, definition is metadata”

There is no DEFINE VIEW statement. The engine parses queries with the official Neo4j parser (cypher-dsl); a non-standard statement would reintroduce pre-parse interception, and Neo4j never executes DDL anyway. The concept splits cleanly:

  • Usage needs zero syntax — a view is a plain label (MATCH (c:my_key_accounts)-[:…]); the parser accepts any label, the registry resolves it. Exactly how Document / HubSpotContact are used today.
  • Definition is a metadata / lifecycle action (define / list / drop, + refresh / scope / TTL) — an API + YAML surface, never a query. The RETURN-bearing body is standard Cypher, parsed by the real parser.
# config/views/key-accounts.yml — durable/shared, like a virtual type today (a realm or world can ship it)
- name: my_key_accounts
materialized: false # regular view — expands at query time
outputLabel: HubSpotContact # the type the view yields (its rows ARE this type)
cypher: |
MATCH (me:AssistantUser)-[:HAS_HUBSPOT_OWNER]->()-[:OWNS_CONTACT]->(c:HubSpotContact)
WHERE c.arr > 100000
RETURN c

The same body can be authored at runtime by the assistant on the acting principal’s behalf (viewService.define(hostScope, name, cypher, materialized), surfaced as a define_view tool) — “save this as my key accounts.” hostScope contains the host-bound world, context, policy revision, and principal; it is never a guest-supplied tool argument.

A view’s query is checked when the realm is validated: a label that is a near miss for one the realm or its world knows (GitHubIsue beside GitHubIssue) is an error at the view’s file and line, naming the likely intended label — at query time the same name is refused (§9), so a view that carries one could never answer.

A view’s own variables are private to it. Only what the view returns becomes the caller’s alias; every other variable its body declares is renamed for each use, so inlining never lets it meet the caller’s. MATCH (o:VaOwner {name:'oc'}) MATCH (x:owned_tier1) RETURN o.name, x.name answers the same whether or not owned_tier1’s body also names an o, and two uses of one view in a query never share its internals. A body that returns an alias of a node (RETURN k AS s) is a node view like one that returns s.

What a view may not be used as. A view is refused, with what to do instead, rather than answered quietly wrong:

  • A node view named like a label the world already holds. Matching that label anywhere — including inside other views — would silently mean “the nodes this view selects”. Rename the view.
  • A view that returns columns, matched as a label. Its rows are not nodes, so MATCH (x:that_view) could only answer nothing; run it by name instead.
  • An argument the view never reads, or a fractional number for an int parameter — the first would be ignored and the second truncated, so the answer would not be the one asked for. A view parameter holds one value: a view that needs a list should read it inside its own query, not as IN $p.

A view may declare its answer instead of its body. goal: in place of cypher: names the final read query only; the world plans the steps that reach it, compiles and pins them, and runs the pinned statement (§16.10).

8.2 Output typing — identity preservation is the rule

Section titled “8.2 Output typing — identity preservation is the rule”

A view you can traverse from always yields nodes of exactly ONE type. Whether it composes is governed by a single rule — does it preserve node identity?

  1. Subset view — RETURN <whole node>: the rows are instances of an existing type (real or virtual), keeping its identity, properties, and edges. Fully composable; the view name is a named subtype (my_key_accounts ⊆ HubSpotContact). Because virtual types materialize onto the real persisted node (prefer-real-node MERGE), a subset view over Document yields nodes that are the persisted documents, carrying OWNED_BY / MENTIONS — the real-vs-virtual distinction dissolves; only identity matters.
  2. Projection view — a reshaping RETURN c.email AS email, count(d) AS docCount: mints the view’s own derived type; composes only via edges the view declares (usually a leaf).
  3. Tabular view — a RETURN of scalars/aggregates: a named result set (report-only), not traversable.

A subset view’s name is a label, so it composes with structural + relevance edges, and with other views (staged materialization to fixpoint):

-- a regular view + an agentic-rag edge + a structured filter, in one standard-parseable query
MATCH (c:my_key_accounts) -- expands + materializes its HubSpotContacts
WHERE c.renewalDate < date('2026-10-01')
MATCH (c)-[r:RELEVANT_TO {via:'agentic-rag', intent:'renewal risk'}]->(d:Document)
RETURN c.email, d.title, r.snippet ORDER BY r.score DESC

A materialized view is queried the same way, but reads the stored subgraph — cheap, no producer calls fire:

config/views/breach-watch.yml
- name: breach_mentions
materialized: true
outputLabel: Document
refresh: "0 6 * * *" # daily 06:00 — reuse the cron scheduler
ttl: 30d # sweep rows this old if a refresh is missed
cypher: |
MATCH (o:Organization) WHERE o.isCustomer
MATCH (o)-[:RELEVANT_TO {via:'agentic-rag', intent:'security incident / breach'}]->(d:Document)
RETURN d
MATCH (d:breach_mentions) RETURN count(DISTINCT d) AS incidents -- an aggregate over a precomputed view

8.4 The materialization cache is pluggable

Section titled “8.4 The materialization cache is pluggable”

The materialized-view cache is a strategy behind an interface, not a fixed mechanism. The default strategy is graph-colocated and transactional: a MaterializedView {view, worldId, contextId, accessPolicyRevision, principalId?, expiresAt} marker node with [:MEMBER] edges to the cached result nodes, swept by TTL. But the store is an SPI:

interface ViewMaterializationStore {
data class CacheState(val expiresAt: Long, val memberCount: Long)
data class RowSnapshot(val columns: List<String>, val rows: List<Map<String, Any?>>, val expiresAt: Long)
fun state(view: String, scope: String): CacheState?
fun clear(view: String, scope: String)
fun sweepExpired()
// A view whose body returns a NODE caches its members.
fun materialize(view: String, scope: String, memberIds: List<String>, expiresAt: Long)
fun bindClause(alias: String, outputLabel: String, view: String, scope: String): String
// A TABULAR view has no node to point a MEMBER edge at, so it caches the rows themselves.
fun materializeRows(view: String, scope: String, columns: List<String>, rows: List<Map<String, Any?>>, expiresAt: Long)
fun rows(view: String, scope: String): RowSnapshot?
}

Two things in that shape are load-bearing.

A view without an outputLabel is a row snapshot, not a member set. Its body returns columns, and there is no node to point a MEMBER edge at — so what is cached is the rows, which is what a client reading the view by name wanted anyway. A store that implements only the member half silently fails every tabular view.

Members are bound by a clause, not returned as ids. bindClause lets the default graph-colocated store prepend pure Cypher (-[:MEMBER]-> off its marker node) with no ids threaded through the query; an off-graph store returns elementId(alias) IN [...] instead. Returning a member list would have forced every store to pay the graph store’s worst case.

scope is the acting caller’s scope as §2 defines it; the store never interprets it, only keys on it.

Every entry is keyed per caller, unconditionally. A view body may use caller-specific credentials, policy, inputs or anchors, and the reference host does not attempt to prove otherwise — there is no analysis that marks a view caller-invariant and no sharing of one entry between callers. That is the conservative choice and it costs: two callers asking for the same view materialize it twice. A host that wants to share reference-data views across callers needs an invariance proof this one does not have, and must not assume the absence of the parameter implies one.

An alternative strategy swaps in without touching the query path: an in-memory LRU for a single-process deployment, an external KV / Redis for a horizontally-scaled one, or an immutable strategy (no TTL, explicit invalidation only) for reference data. The refresh policy (ttl, cron refresh:) is orthogonal to the store.

8.5 A general result / entity cache — and negative results

Section titled “8.5 A general result / entity cache — and negative results”

The same store generalizes beyond views to a producer result cache — the answer to “should API fetches be cached?” A principal-dependent producer call is (worldId, contextId, principalId, access-policy digest, realm/producer digest, source/credential revision, producer args/key) → records; every component participates in the cache key. principalId may be omitted only for a producer the host verifies is principal-invariant. Deployment-approved public/reference producers use a separate explicitly public namespace and dataset revision. Caching is governed by the §9 diagnostics:

  • Positive result — a genuine, successful fetch (including a real “no records”) is cacheable with a per-producer ttl (or immutable for stable reference data). A repeat query for the same key reads the cache, and so does a narrower question over the same key where §5.13 allows it; no producer call fires.
  • Negative (entity) result — a known miss for an ENTITY (this login / email / domain resolves to nothing) is cached too, so a fan-out doesn’t re-probe a dead key every query. This is a store with a TTL and invalidate-on-reconnect: reconnecting an integration re-attempts every key previously missed for that caller, so a miss recorded under a broken credential is never written off permanently. The miss is keyed, not stamped — earlier hosts wrote a _bridgeMissAtMs_<target> timestamp onto the anchor itself, which put a system property into keys(n) where schema snapshots, property enumerators and a plain RETURN n could all see it. A cache must not leak into the data model, and eviction must not be a scan of every property of every node a caller owns; keying the entry gives both.
  • Never cache a FAILURE. A timeout / 5xx / expired-auth fetch is not a result — caching its emptiness would hide the data once the integration heals. Only a PRODUCER_ERROR-free outcome is cacheable (§9).

The identity bridge (who an external identity is, §5.2) is the one already-persistent positive cache; the producer result cache and the entity negative-cache are the same idea applied to what a key holds, and to keys that hold nothing — all behind one pluggable store, so a deployment picks graph-colocated, in-memory, or external as it scales.

8.6 Persisted scopes, and consuming a scope as typed instances

Section titled “8.6 Persisted scopes, and consuming a scope as typed instances”

A materialized view refreshes (it re-runs its body on TTL expiry). A persisted query is the other lifecycle over the same MEMBER-set store: a saved result set addressed by an opaque handle (query:<uuid>), that does not re-run — it is a scope of immutable results that survives until explicit deletion. The two differ only in metadata, so the store from §8.4 generalizes with three fields rather than a new mechanism:

  • expiresAt becomes nullable — a TTL for a view, null (pinned) for a persisted query;
  • a refreshable flag — true for a view (has a body to re-derive), false for a persisted query (immutable snapshot; any body is kept only as provenance);
  • list() / GC — so pinned entries are enumerable and reclaimable.

Because a persisted scope is bound as a label (MATCH (x:query_7f3a) …) exactly like a materialized view, nothing on the read path changes: a scope is a scope, whether named-and-refreshing or handle-and-pinned.

Two properties are frozen at capture, decided per store: membership (which nodes are in the scope) is always frozen — that is the MEMBER edge set. Values are not, by default: the edges point at live nodes whose properties keep changing. A store that needs a true point-in-time snapshot copies the projected columns at capture; a virtual/fetched member is already a committed copy (prefer-real-node MERGE, §8.2·1), so it is naturally frozen.

Consuming a scope: because a subset scope RETURNs whole, identity-preserving nodes (§8.2·1), a client runtime can hydrate its members directly into typed instances — the type comes off the node’s own label, so the reader needs no per-query type argument. This is the source side of the type-and-function model: the saved scope supplies the objects; their methods (pure compute, or effectful write-back through the producer) live on the type. A tabular scope (§8.2·3) has no node to hydrate — it is a frozen values table, readable and renderable but never bound as a label or hydrated into instances. The store records which kind a handle is and refuses label-binding / hydration on a tabular one, so an unsupported consumption fails honestly rather than producing wrong Cypher.