3.4 Predicate pushdown — scope the fetch at the source
MATCH (p:Person {name:'Grace Hopper'})-[:HAS_GITHUB]->(g)-[:RAISED]->(i:GitHubIssue)WHERE i.html_url CONTAINS 'acme/app'RETURN i.titleMeans: “Grace’s issues in the acme/app repo.”
How it executes (with pushdown):
- Probe + resolve the bridge as before →
g.login = 'devone'. - Fetch — pushed down. Instead of fetching all of devone’s issues and discarding the ones
not in
acme/app, the engine renders theWHERE i.html_url CONTAINS 'acme/app'predicate into the source’s native filter: the GitHub search becomesis:issue author:devone repo:acme/app. One scoped search returns only the matching issues. - Materialize / run / roll back as usual. The same
WHEREstill runs in the graph too, so correctness never depends on pushdown — it only changes cost and coverage.
How it executes without a pushdown rule declared: the engine fetches devone’s issues
broadly (paged, capped) and the graph-side WHERE drops non-matches. Correct, but wasteful, and
it can hit the source’s result cap before your matches (see §3.5).
Declared: a pushdown: rule on the issuesByAuthor producer mapping the html_url predicate
to a repo:{value} qualifier (with a valuePattern regex to extract owner/repo from a URL).
A captured realm’s handler producer declares pushdown as property and argument pairs.
The handler receives every value an = or IN allows as a list of strings under that
argument, and applies it however its source spells a filter. The graph still runs the WHERE,
so the guarantee above holds unchanged
(hosted profile).
If your source’s query is JSON, not text — an Odoo domain, an Elasticsearch bool.filter, a
GraphQL where: — declare argPath + clause instead of qualifier. The clause is placed in the
producer’s own args, addressed the way keyArg is, with a trailing - meaning “append here”:
pushdown: - property: status op: IN argPath: domain.- clause: ["status", "in", "{values}"]"{values}" becomes the real member list, so the source gets JSON rather than text shaped like it.
Appending is what keeps an existing keyArg: "domain.0.2" pointing at the triple it always did.
Why it is worth declaring, beyond saving a page. What the source absorbs decides what else the
engine may do. With every predicate on the target absorbed, a LIMIT can stop the page walk (n rows
fetched are n rows answered; SKIP s LIMIT n stops it at s + n, since the skipped rows must be read
to be skipped), and a count() can be asked of the source instead of fetched — one call for the
whole question rather than one per anchor. Neither is safe while the graph still has filtering left
to do, so an undeclared filter costs more than its own page.
A read is reused for the narrower questions inside it. Within a producer’s cache window, the
engine does not re-read the source for a question that a cached complete read for the same key
already contains: the unfiltered read of an account’s cases serves WHERE c.status = 'open' over
the same account, and a LIMIT 50 read serves LIMIT 10 in the same declared order, each with no
call and reported as cached. It never works the other way — the filtered read does not serve the
unfiltered one, and a truncated read serves nothing but itself — so in a family of views, run the
broadest first (VIRTUAL_CYPHER §5.13).
When there is nothing to declare, say so: pushdown: []. A thread endpoint that returns the
whole conversation, a list keyed by status and nothing else — some sources apply no filter at all,
and every WHERE on their target is the graph’s by construction. An absent pushdown: key cannot
express that; it reads as “not declared yet”, so the realm is warned at load and every read past a
handful of records carries advice to declare a rule that cannot exist. A declared empty list is the
author’s statement that the source filters nothing: the load warning goes, the read’s cost note
says the source applies no filters, and EXPLAIN names the residual without telling you to declare
it. The note itself stays, because it is what marks the read as complete and its empty as exact.

