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, soMATCH (c:my_key_accounts)-[:R]->(x)becomesMATCH <view body> MATCH (c)-[:R]->(x). Always fresh; no storage. Inlining is flat, not aCALL {}subquery: the view’s members bind the outer alias directly, so the rest of the query traverses from them as from any matched node. (UnitCALL {}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 howDocument/HubSpotContactare 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 cThe 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
intparameter — 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 asIN $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?
- 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 overDocumentyields nodes that are the persisted documents, carryingOWNED_BY/MENTIONS— the real-vs-virtual distinction dissolves; only identity matters. - 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). - Tabular view — a
RETURNof scalars/aggregates: a named result set (report-only), not traversable.
8.3 Composing views
Section titled “8.3 Composing views”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 queryMATCH (c:my_key_accounts) -- expands + materializes its HubSpotContactsWHERE 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 DESCA materialized view is queried the same way, but reads the stored subgraph — cheap, no producer calls fire:
- 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 dMATCH (d:breach_mentions) RETURN count(DISTINCT d) AS incidents -- an aggregate over a precomputed view8.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(orimmutablefor 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 intokeys(n)where schema snapshots, property enumerators and a plainRETURN ncould 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:
expiresAtbecomes nullable — a TTL for a view,null(pinned) for a persisted query;- a
refreshableflag — 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.

