Skip to content

The filter tree is the only filter grammar - #2785

Merged
shivamka1 merged 11 commits into
filter_expr_wipfrom
filter-wire-expr
Sep 17, 2026
Merged

shivamka1 merged 11 commits into
filter_expr_wipfrom
filter-wire-expr

Conversation

@shivamka1

Copy link
Copy Markdown
Collaborator

Stacked on #2643 (filter_expr_wip); merging this lands there.

What changes

#2643 gave every filter a typed expression, but a filter still had to travel to a server, or sit in a stored permission grant, in the old per-entity description grammar (node: { property: { name, where } }, window: { …, expr }). Python kept two representations per filter object, one to run and one to send, and anything without a description form (an expression on the right-hand side) could not be sent at all.

This PR makes the expression tree the only form a filter has:

  • Rust gains filter/model/tree.rs: Expr (a const, a read of a field/degree/property/metadata, or temporal/sum/any/… over one) and FilterExpr (comparisons with an Expr on both sides, string ops, presence, membership, structural tests, views, and/or/not). It compiles onto the erased factories in dyn_factory.rs, so one compiler serves Rust, Python and GraphQL.
  • Python filter objects hold the tree and nothing else. Building a filter extends the tree; running it compiles the tree; repr() prints it. The composite filters and the wire recorder are gone.
  • GraphQL accepts the tree as FilterExpr, with one input type per tree node (Expr, Read, Scope, ViewOp, Target, Cmp, Membership). Every filter(expr:) / select(expr:) takes it. The view collections' nodeFilter and edgeFilter entries collapse into one filter entry, since the tree names the entity it tests.
  • The per-entity grammar is deleted: GqlNodeFilter, GqlEdgeFilter, GqlExplodedEdgeFilter, GqlGraphFilter, their wrapper structs, and the lowering that converted them. GqlFilter in Rust is the tree type. The namespace metagraph filter keeps its own where grammar; it filters graphs by metadata and was never part of this.
  • The remote client sends the tree as a FilterExpr! variable. degree() > in_degree() now travels like anything else.

Breaking

GraphQL documents and stored permission grants written in the per-entity grammar must be rewritten as trees; there is no conversion on load. The ~450 test fragments in this repo were converted by a script whose output was checked against the old lowering before that lowering was deleted. The pometry-storage side is Pometry/pometry-storage (branch filter-wire-rbac), which must merge after this.

Other fixes folded in

  • Numeric comparisons against a constant wider than the property type compare by value (Prop::compare/equals), as the property filters always did; risk <= 2**63 + 5 matched nothing before.
  • An endpoint read (Edge.src().property(...)) keeps the edge's views; the window was dropped before.
  • The tree input structs reject unknown fields like every other input object.
  • The user-guide filtering page is rewritten for the expression API, with examples that run.

Verification

suite result
cargo test -p raphtory-graphql 236 + 1 passed
Python test_base_install (incl. GraphQL e2e and local/remote parity) 3393 passed · 15 skipped · 9 xfailed
pometry-storage against this head raphtory-auth 137 passed (incl. the restriction-equivalence property tests); RBAC Python 344 passed · 1 xfailed
schema, stubs, mypy gate, user-guide examples regenerated / clean / all assertions hold

A filter as plain data: Expr for values and FilterExpr for predicates, one
node per operation of the typed API, with expressions allowed on both sides
of a comparison. compile() replays a tree onto the typed factories, so a
tree built anywhere runs as the same filter everywhere. The type-erased
factories move out of the python bindings into the core model, since the
compiler needs them without the python feature.
GqlFilter gains an `expr` variant carrying the tree as GraphQL input types
that mirror it one to one, so a filter with expressions on both sides of a
comparison can be sent. The legacy grammar is converted onto the tree on
arrival and nothing executes it any more: GqlFilter, DynFilter and DynView
all compile from the tree. A tree can be applied to a graph directly.
A python filter is one value: the tree. Local filtering compiles it,
the remote client sends it, repr prints it, so there is nothing to keep
in step. With no consumer left, the composite filter enums, FilterTree,
the wire recorder and the engine-to-wire converters are deleted. The
one filter built from in-process state, by_state_column, is an opaque
leaf that runs locally and refuses to serialise.
qp3.rs was a probe that only printed, and py was a stray file swept in
by a bulk add.
… become trees

The lowering now produces the tree grammar itself, so a policy placeholder
({"var": …}, {"claim": …}) stays a wire value and is substituted later as
before; membership takes one list value for the same reason.
GqlFilter::into_tree_grammar is the one conversion a store calls, and a
tree-grammar `and` in a row filter applies its parts in order like the
legacy `and`. Data::access_filter_applies lets a policy tell a caller's
value the graph cannot compare from a grant that is wrong.
The per-entity input types (GqlNodeFilter, GqlEdgeFilter,
GqlExplodedEdgeFilter, GqlGraphFilter, their wrapper structs) and the
lowering that converted them onto the tree are removed. GqlFilter is now
the tree type itself, published as FilterExpr, and every filter and
select argument takes it. The view collections' nodeFilter and edgeFilter
entries collapse into one filter entry, since the tree names the entity
it tests. The remote client declares its variables as FilterExpr.

filtering.rs keeps only the view collections, the access-filter types and
the PropCondition grammar the namespace metagraph filter still uses.
DegreeDirection moves next to the tree input types. The tree input structs
reject unknown fields like every other input object.

Tests spell their filters in the tree grammar; the schema and stubs are
regenerated.
The page described filter_nodes, filter_edges and the removed builder
classes. It now explains the expression tree in plain terms: where a
filter starts, what it can read, how it compares, combining, reading
through a view, property history, edges, and how filter applies on a
graph, a node collection and a node. Every example runs against a small
inline graph and asserts its answer.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark 'Rust Benchmark'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 2.

Benchmark suite Current: 07e3ee4 Previous: 9823ef7 Ratio
lotr_graph/num_edges 6 ns/iter (± 0) 0 ns/iter (± 0) +∞
lotr_graph/num_nodes 6 ns/iter (± 0) 1 ns/iter (± 0) 6
lotr_graph/graph_latest 3 ns/iter (± 0) 0 ns/iter (± 0) +∞
lotr_graph_materialise/materialize 7680537 ns/iter (± 53574) 1564816 ns/iter (± 35303) 4.91
lotr_graph_window_100/num_nodes 19 ns/iter (± 0) 5 ns/iter (± 0) 3.80
lotr_graph_window_100_materialise/materialize 7747316 ns/iter (± 21943) 1669150 ns/iter (± 10700) 4.64
lotr_graph_window_10/has_node_existing 137 ns/iter (± 8) 62 ns/iter (± 11) 2.21
lotr_graph_window_10_materialise/materialize 3374027 ns/iter (± 17398) 971980 ns/iter (± 4278) 3.47
lotr_graph_subgraph_10pc/has_node_nonexisting 5 ns/iter (± 0) 2 ns/iter (± 0) 2.50
lotr_graph_subgraph_10pc_materialise/materialize 2249104 ns/iter (± 34866) 334634 ns/iter (± 1287) 6.72
lotr_graph_subgraph_10pc_windowed/has_node_existing 144 ns/iter (± 9) 62 ns/iter (± 14) 2.32
lotr_graph_subgraph_10pc_windowed_materialise/materialize 1486269 ns/iter (± 10727) 230399 ns/iter (± 2617) 6.45
lotr_graph_window_50_layered/num_edges_temporal 144764 ns/iter (± 2458) 70121 ns/iter (± 7586) 2.06
lotr_graph_window_50_layered/has_node_existing 399 ns/iter (± 22) 129 ns/iter (± 12) 3.09
lotr_graph_window_50_layered/graph_latest 82933 ns/iter (± 1314) 36649 ns/iter (± 916) 2.26
lotr_graph_window_50_layered_materialise/materialize 30045339 ns/iter (± 219570) 3488825 ns/iter (± 24948) 8.61
lotr_graph_persistent_window_50_layered/num_edges_temporal 587510 ns/iter (± 6543) 192686 ns/iter (± 1569) 3.05
lotr_graph_persistent_window_50_layered/has_node_existing 407 ns/iter (± 388) 174 ns/iter (± 83) 2.34
lotr_graph_persistent_window_50_layered/iterate_exploded_edges 3350197 ns/iter (± 75347) 1659940 ns/iter (± 19402) 2.02
lotr_graph_persistent_window_50_layered/graph_latest 132252 ns/iter (± 2456) 57549 ns/iter (± 4809) 2.30
lotr_graph_persistent_window_50_layered_materialise/materialize 51487010 ns/iter (± 199687) 5298035 ns/iter (± 147912) 9.72

This comment was automatically generated by workflow using github-action-benchmark.

The converted degree tests used f-strings with nested double quotes,
which Python 3.11 rejects at compile time. The GraphQL benchmark built
its name filter in the removed grammar; it is now a tree.
A view inside a compiled and, or or not used to vanish: the intersection
of entity filters never saw the nodes a window hides through time
semantics, so window & pred returned pred alone and ~window returned
everything. The RBAC row filter papered over this with a sequential
special case for a grant's top-level and, which gave the same text two
meanings depending on where it sat.

Now the tree compiler applies the view legs of the top-level and first
(nested ands flatten) and runs the other legs inside that view, the way
graph.window(..).filter(pred) does. A view under or or not is refused
when the filter is compiled and, in Python, where it is written. The
row filter applies the tree as is. Reads through an exploded edge's
endpoint are refused too, since the tree has no wrapper for them, and
the opaque filter's refusal to serialise is pinned by a test.

The at/after/before window arithmetic is shared between the wrapper ops,
the erased edge factory and the view compiler instead of being copied.
The collection and parity tests pin the composition rule; the
set-algebra expectations no longer cover view legs, which are not set
algebra.
The remote client raises ValueError for a filter that has no server-side
form, as its methods document; the twelve client docstrings that still
described the removed NodeFilter and EdgeFilter wire types say so too.
latest: false and snapshotLatest: false in a ViewOp are refused instead
of being applied. Set members and presence tests are checked when a
filter is written, like comparisons already were.

The warning on get_graph_unchecked is back on that method, and
access_filter_applies carries its own. DegreeDirection uses the same
serde casing as its siblings. The four schema types that lacked a
description have one, the component resolvers and graph.nodes/edges
select arguments describe the tree instead of the removed grammar, and
the namespace filter's PropCondition helpers are private again. Dead
field-name constants, two unused type aliases and stale comments about
the deleted composite path are removed.

The parity test for expression right-hand sides reads degrees at the
deferred sites, so a remote that dropped the conjunct would fail it. The
schema and stubs are regenerated.
The benchmark's TypeScript types are generated from the schema by genql,
which makes a field's args object mandatory when an argument description
contains a colon. The reworded select and filter descriptions had one, so
every nodes and edges selection in the benchmark stopped type-checking.
@shivamka1
shivamka1 marked this pull request as ready for review September 17, 2026 11:16
@shivamka1
shivamka1 merged commit b2dae04 into filter_expr_wip Sep 17, 2026
16 of 29 checks passed
@shivamka1
shivamka1 deleted the filter-wire-expr branch September 17, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant