Skip to content

5.11 Enumerated anchors — WHERE anchor.key IN [...]

A virtual anchor is normally pinned to ONE value ((q:IntegrityQuery {abn:'…'})). The BATCH form is an enumerated IN over literals, and it behaves identically — one anchor per value:

MATCH (q:IntegrityQuery)-[:HAS_TAX_RECORD]->(x:TaxRecord)
WHERE q.abn IN ['31010545267', '29008423005', '46221314841']
RETURN q.abn, x.name, x.taxPayable

This is the shape every screening question takes — these twenty suppliers against that register — and it fetches ONCE for the batch (the producer’s batch contract), not once per value.

Guarantees

  • Each value is probed for a REAL node first, exactly as a pinned anchor is; only values with no real node are minted as virtual anchors. A mixed list works.
  • Duplicates collapse: one anchor per distinct value.
  • An anchor the source answered for is there even when the answer is none. MATCH (r:GitHubRepository {full_name:'acme/empty'}) OPTIONAL MATCH (r)-[:HAS_ISSUE]->(i) RETURN r.full_name, count(i) answers acme/empty, 0, and an IN list mixing an empty value with a full one keeps the empty one’s row. A value the source answered 404 for, or whose fetch failed, is not minted; the answer carries that warning instead (§9).
  • An anchor nothing fetched is explained, not answered empty. MATCH (r:GitHubRepository {full_name:'acme/gears'}) RETURN r.full_name traverses no join, so nothing asked the source whether the repository exists, and a row built from the query’s own literal would assert it on no evidence. The empty answer is typed as a query defect, ANCHOR_UNFETCHED, and its hint names the label’s joins and the OPTIONAL MATCH that would answer. A label with stored instances and no match for the pin is a true negative and gets no hint.
  • It composes with the rest of the WHERE. An IN combined with other conditions by AND still seeds (this matters more than it looks: scope conditions are ANDed onto every query, so an enumerated anchor is essentially always inside a compound).
  • A null in the list seeds nothing, and voids nothing. IN ['a', null] seeds 'a' — as Cypher’s IN is true only for 'a'. A boolean member seeds as its token.
  • A member that is computed is resolved. An expression (toLower('ACME/WIDGETS')), a value bound by an earlier WITH or UNWIND, or a list-valued variable (WITH ['a','b'] AS ks … IN ks) is evaluated before the fetch, exactly as {key: expression} is, and seeds what it evaluates to.
  • Alternatives on one key enumerate their union. k IN ['a'] OR k = 'b' seeds 'a' and 'b' — the same set written two ways.
  • A list narrowed by a ranked LIMIT fetches for the anchors the LIMIT keeps. In MATCH (s:Shop) WHERE s.code IN ['s1', …, 's6'] WITH s ORDER BY s.rank LIMIT 3 MATCH (s)-[:HAS_STOCK]->(x) the hop is asked for the three shops the ranking keeps, not the six the list names. That holds when every listed value resolves to a stored node and the LIMIT is ranked by an ORDER BY over this anchor. Where it does not — a value no stored node holds (a minted anchor has no sort key to rank by), an unranked LIMIT (the k a probe keeps need not be the k the query keeps), or an anchor label this query is itself fetching — every listed anchor is bound and fetched for, and the graph narrows afterwards: a fuller fetch, never a wrong k.

A parameter list seeds exactly as the literal list does. WHERE q.abn IN $abns with abns supplied answers the same rows as the same values written inline — supplied parameters are substituted before the query is read. A parameter nobody supplied is refused, never read as an empty list.

A fetched node pinned to a value bound upstream is narrowed to it. In MATCH … WITH x MATCH (a)-[:HAS_FIELD]->(f:Field {key: x})-[:ACCESSED_BY]->(m), the node a join FETCHES (f, not the anchor) is pinned to x, which an earlier clause bound. Its values are resolved before the fetch, and only the matching rows are kept — handed to a source that applies IN filters as one, filtered on receipt otherwise — so the next hop (ACCESSED_BY) is asked for those keys alone, as it would be for {key: 'C.f3'} written inline. The same holds for WHERE f.key = x (either operand order), f.key IN xs, and a pin to another node’s property bound earlier ({owner: frame.className}). WHERE f.key = x pins exactly as {key: x} does when the condition is an AND of terms; under an OR it is a filter, and a comparison between the properties of two nodes of the same MATCH (a.id = b.ownerId) is a join, never a pin. When an EARLIER MATCH bound b, the comparison is the inline pin {id: b.ownerId} and pins as that does, either operand order.

What it guarantees and what it does not:

  • The same rows as without it. Narrowing never drops a row the query keeps; OPTIONAL MATCH keeps its null rows.
  • It holds back rather than guess. When the earlier part of the query still reads data not yet fetched, when there are more than 5,000 distinct values, when none resolve, or when a value is not a plain scalar, the node is fetched in full and the pin applied afterwards — a fuller fetch, never a wrong answer.
  • It narrows what is fetched only where the source can be asked for it. A source keyed by something else (a whole-collection producer) still reads its collection; only what is kept, and every later hop, shrinks. A realm that wants the read itself narrowed declares a producer selected by that property.

An anchor keyed by a value computed from fetched rows is fetched for each value. In

MATCH (me:AssistantUser)-[:HAS_LOG_EVENT]->(e:LogEvent)-[:HAS_FRAME]->(f:JavaStackFrame)
WITH DISTINCT f.jarPath AS jar
MATCH (j:Jar)-[:HAS_PACKAGE]->(p:JavaPackage) WHERE j.path = jar
RETURN jar, count(p)

the jar is keyed by every value jar holds once the frames are fetched — however many fetches before it they are — exactly as (j:Jar {path: 'jars/app.jar'}) keys it. The same holds for {path: jar} inline, for a key read straight off a node an earlier MATCH bound (WHERE j.path = f.jarPath), and for a key aggregated or unwound upstream; a computed pin on the fetched node (… UNWIND causes AS cause … WHERE j.path = jar AND c.calleeOwner = cause) applies on top, as above.

  • Never an empty answer that is not one. If the values can never be read — the earlier part of the query reads data this query does not fetch — the join is not fetched and the answer carries a PARTIAL_RESULT warning naming the anchor, the value its key is computed from, and the join.

What does NOT seed, and why

  • A filter that cannot list keys — STARTS WITH, CONTAINS, a range, a regex on the key. Nothing can be fetched, and the answer SAYS so: a NEEDS_FILTER warning names the label and the key to pin, so an empty result is never read as “there are none”. The same warning accompanies an anchor that is not pinned at all.
  • An OR across different properties or nodes — WHERE q.abn IN [...] OR q.name = '…'. One branch of such a disjunction must not enumerate the anchor set. (Alternatives on the SAME key are an enumeration, above.)
  • The mirror form — WHERE 'X' IN n.someList narrows a node by list membership; it is a filter, not an enumeration of identities.