Skip to content

Type-safe filter expression API - #2643

Draft
shivamka1 wants to merge 242 commits into
db_v4from
filter_expr_wip
Draft

shivamka1 wants to merge 242 commits into
db_v4from
filter_expr_wip

Conversation

@shivamka1

@shivamka1 shivamka1 commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Type-safe filter expression API

What this PR does

A filter is one thing everywhere: an expression tree. The Rust builder, Python and
GraphQL all produce it, one compiler turns it into typed expression structs and their
ops, the remote client sends it, and permission grants store it. The old composite and
builder paths are deleted, and so is the per-entity GraphQL grammar
(nodeFilter: { property: { name, where } }, window: { …, expr }).

Vocabulary used throughout: a term is a value taken from the node or edge under test
(property, temporalProperty, metadata, field, degree, isActive, an endpoint's
term through src/dst); a literal is a constant you write; an aggregate
collapses a list (sum, avg, min, max, first, last, len, earliest,
latest); a view scopes what a term sees.

g.filter(NodeFilter.degree().gt(5))?;
g.filter(EdgeFilter.src().name().eq("alice"))?;
g.filter(NodeFilter.property("score").temporal().max().ge(10i64))?;
g.filter(EdgeFilter.window(1, 3).property("p").eq(1u64))?;   // the view is on the term
filter.Node.property("score") > 4                 # a literal on the right
filter.Node.degree() > filter.Node.in_degree()    # another term on the right
filter.Node.window(0, 5).property("score") > 4    # a term inside a window
filter(expr: { node: { cmp: { op: GT,
                             lhs: { read: { property: { name: "score",
                                                        views: [{ window: { start: 0, end: 5 } }] } } },
                             rhs: { const: { f64: 4.0 } } } } })

The layers

  1. Rust: filter/model/expr/mod.rs defines the tree. FilterExpr is
    node/edge/explodedEdge/view/and/or/not; each entity leg is an
    Expr<Leaf> over that entity's own terms, so an edge-only operation inside a node
    expression is a compile error. expr/builder.rs is the typed API: NodeFilter,
    EdgeFilter, ExplodedEdgeFilter, GraphFilter and their methods build the tree,
    so Rust callers, Python and GraphQL meet in the same data. expr/compile.rs turns a
    tree into the typed expression structs in node_expr/ (BinaryCmpExpr, StringExpr,
    PropValueSetExpr, UnaryExpr, AnyExpr/AllExpr, the aggregates,
    AndExpr/OrExpr/NotExpr), each of which owns its type rules and compiles to an
    op once. expr/stream.rs evaluates aggregates and any/all over a history without
    collecting it. expr/split.rs rewrites a tree, before it is compiled, into the
    views to apply and the two questions it answers (which nodes stay, which edges
    stay); compile.rs compiles that once for every place a filter runs.
  2. Python: a filter object holds the tree and nothing else. Every builder method
    calls the Rust builder of the same name, running compiles the tree, repr() prints
    it back. Comparisons, membership, presence
    and any()/all() are refused where they are written when the left side's type is
    known statically.
  3. GraphQL: one @oneOf input per entity (NodeExpr, EdgeExpr,
    ExplodedEdgeExpr), each declared plainly with the crate's derives and mirroring the
    core tree one variant to one type: const, read, cmp { op },
    str { op }, isIn/isNotIn, presence { op }, agg { op }, quantified { op },
    and/or/not; operators are enum values, a read is a one-of naming its term
    (property: { name, views }, field: { name: NAME, views }, src: { expr, views },
    …), so each term carries its own views and nothing else, and the argument-less view
    operations are one kind enum. The conversion to
    and from the tree is written once over a small per-entity trait. Every filter(expr:)
    and select(expr:) takes it. Policy placeholders (var, claim) are Value variants.
  4. Remote Python: sends the tree as a FilterExpr! variable. The one filter that
    cannot travel, by_state_column, raises ValueError at the client.

Breaking changes

  • GraphQL callers and stored grants written in the per-entity grammar must be
    rewritten as trees; nothing converts on load. The matching pometry-storage change is
    Pometry/pometry-storage#405: the store keeps trees, rejects the old grammar with a
    message that says what to rewrite, and its ABAC scanner reads placeholders out of the
    tree.
  • applyViews is removed everywhere, with the ViewCollection, NodesViewCollection
    and GraphViewCollection inputs. filter(expr: { view: [ …ViewOps… ] }) does what they
    did, in the same order; a predicate that narrowed a collection is select(expr:). The
    view operations now include excludeNodes, subgraph, subgraphNodeTypes and valid,
    so nothing the collections offered is lost. Two behaviours move with it: layers and
    excludeLayers name layers that must exist, while validLayers and
    excludeValidLayers ignore unknown names as the collections did; and
    latest: false-style no-ops are refused. A property, degree or is_active term read
    through a view that hides nodes or edges answers for the hidden ones as it does for
    ones a window leaves out; a view written on name(), id() or node_type() is kept
    for display and ignored when the filter runs, since those fields have no time axis.
  • An edge collection remembers what it holds. edges holds edges and
    edges.explode() / explode_layers() hold exploded edges; both stay one type,
    Edges (and NestedEdges, EdgesWindowSet) in Python and GraphQL and
    RemoteEdges / RemoteNestedEdges in the remote client. [] and select ask
    their question once per item of the collection they are called on: per edge on
    edges, per exploded edge on edges.explode(), whatever kind of predicate they
    are given. A view used as a predicate on edges therefore keeps whole
    edges and never trims their updates; graph.window(..).edges is the form that does.
  • sum(), avg(), min() and max() require numeric elements, as before the tree;
    a string or boolean history is refused with the aggregate and element type named.
  • The bundled UI (raphtory-graphql/resources/index.html) still issues the old
    grammar (nodeFilter/edgeFilter with where:) and applyViews, so the UI test
    jobs are red until the UI is rebuilt against schema.graphql.
  • nodes.filter() defers uniformly for every filter kind (as Node ID and OR filter bug #2754 did upstream);
    membership narrowing is nodes[...], select, or graph.filter.

Semantics decided

  • What a view leg means depends on where the filter is applied. On graph.filter
    a view leg of the top-level and applies first and the other legs run inside it, the
    way graph.window(..).filter(expr) does; nested ands flatten, and a view under or
    or not is refused there, since no graph is "windowed or filtered" or "not windowed".
    On a collection (nodes[..], edges[..], select) every leg is a test on the
    collection's own graph: a view leg asks that the item exist in the view, the other legs
    read the graph itself, and &, | and ~ have their plain set meanings, so
    edges[~Graph.window(0, 5)] is the edges that do not exist in the window. Two view
    legs are two tests on a collection and one chained view on a graph. Closes Negating a view-scoped filter is a no-op; the intended semantics are undefined #2718: a
    negated view is refused where it has no meaning and defined where it has one.
  • A view on a term scopes only that term: Node.window(0, 5).property("score") is
    the score inside the window and the node comes back unwindowed. In GraphQL the views sit
    on the read itself and apply in list order. The view
    operations are window, at, after, before, latest, snapshotAt,
    snapshotLatest, layers, validLayers, defaultLayer,
    excludeLayers/excludeLayer, excludeValidLayers,
    shrinkStart, shrinkEnd, excludeNodes, subgraph, subgraphNodeTypes and valid,
    each equal to the graph view method of the same name.
  • Literals keep db_v4's family rule: numbers compare by value across widths, a
    string never compares with a number, nothing is converted. is_in drops members
    outside the family. Two maps are comparable when every key they share is; a key only
    one side has does not get in the way, because a map property's declared type is the
    union of the shapes its values take.
  • Endpoint terms keep the edge's views: Edge.window(0, 5).src().property("x")
    reads inside the window.
  • Name equality resolves its node through the id index instead of scanning, as the
    id field does.
  • Empty and/or/view are refused.
  • A filter answers two questions, which nodes stay and which edges stay; a node
    predicate answers the second by "both ends stayed", an edge predicate keeps every
    node. and, or and not combine the answers question by question, so
    name == "b" | name == "c" keeps the edge b→c, ~ of a node predicate keeps the
    nodes that fail it and the edges between them, and ~(node & edge) negates each
    answer. An or between a node predicate and an edge predicate is the union of what each keeps: every node, and the edges either leg keeps (settled while fixing the grant union in Pometry/pometry-storage#405, where several roles' filters are combined with or). A node
    collection refuses any filter that tests edges. graph.edges[f] and
    graph.filter(f).edges agree for every tree.
  • Invalid chains fail early, with the same message locally and remotely, and the
    message says what to write instead: a history compared without any/all, a
    qualifier on a single yes/no, an aggregate of a scalar, is_some on a term that
    always has a value, two terms of different types.

Performance

  • Aggregates and any/all over a temporal history stream the history; nothing is
    collected into a list first.
  • A node term decides the node's membership in its view once, and the erased graph it
    reads from is not re-boxed on every evaluation.
  • Node-id and name filters resolve their domain through the storage index instead of
    scanning (the Node ID and OR filter bug #2754 narrowing, carried onto expressions).

Follow-ups filed

Verification

suite result
Rust: raphtory + raphtory-graphql + raphtory-tests (merged with db_v4 at 1d6ac0b32) all passing; test_filters 670 scenarios, raphtory-graphql 247
Python test_base_install (incl. GraphQL e2e and local/remote parity) 3420 passed · 15 skipped · 9 xfailed; mypy over the stubs clean
pometry-storage #405 against this head raphtory-auth incl. the restriction-equivalence property tests; integration-tests 111
CI on this PR Rust, Python, benchmark and doc jobs green; UI jobs red until the UI bundle is updated

shivamka1 and others added 25 commits June 4, 2026 16:17
…ps with direct methods on Id accepting T: Into<GID>
…ing NodeNameFilterBuilder/NodeTypeFilterBuilder
…ing NodeNameFilterBuilder/NodeTypeFilterBuilder
…_expr

# Conflicts:
#	raphtory/src/db/graph/views/filter/model/node_expr.rs
…e full filter-building pipeline on each type
…ilder → TemporalProp/Quantified/Aggregated

@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: f3be241 Previous: 20e0972 Ratio
lotr_graph/num_edges_temporal 350 ns/iter (± 13) 151 ns/iter (± 0) 2.32
lotr_graph/num_edges_temporal_cached 336 ns/iter (± 16) 154 ns/iter (± 1) 2.18
lotr_graph_window_100/num_edges_temporal 362 ns/iter (± 11) 162 ns/iter (± 4) 2.23
lotr_graph_window_100/num_edges_temporal_cached 361 ns/iter (± 10) 161 ns/iter (± 0) 2.24
lotr_graph_subgraph_10pc/num_nodes 15 ns/iter (± 0) 4 ns/iter (± 0) 3.75
lotr_graph_subgraph_10pc/num_nodes_cached 16 ns/iter (± 0) 4 ns/iter (± 0) 4

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

`RemoteExplodedEdges` and `RemoteNestedExplodedEdges` held the same client
as `RemoteEdges` and `RemoteNestedEdges`; `explode` returns the class it
was called on, as the server now returns one `Edges` type.
`ExplodedEdges` and `NestedExplodedEdges` offered the same methods as
`Edges` and `NestedEdges`; the collection now carries its kind, so
`explode` returns the class it was called on and `[]` on the result
still asks per exploded edge. The repr reads `Edges(` again.
A read is one term read through optional views: a property, a temporal
property or a metadata entry by name, a built-in field, or on an edge a
node expression at either end. The term is a GraphQL one-of, so a read
with no term or several is refused by the schema, not by a check of our
own; `views` sits with the term it scopes. A `Prop` always has a wire
form, so the conversion to `Value` is infallible.
The field moves between each entity's expression enum and the shape
every entity shares were the same three times over; the conversion itself
is written once in `EntityInput::into_tree` / `from_tree`.
The six window sets had the same `count`, `page` and `list`; one
`GqlWindowSet<T>` serves them all, keeping each GraphQL name and
description. A graph's windows are built in the graph's folder, so only
the graph window set takes that path.
A layer view errors on a layer the graph does not have; `valid_layers` and
`exclude_valid_layers` ignore it, as the graph methods of the same name do.
The stress test asks for one beside the strict layer view.
shivamka1 and others added 2 commits October 9, 2026 15:42
# Conflicts:
#	raphtory-graphql/src/model/graph/filtering.rs
#	raphtory-tests/src/assertions.rs
#	raphtory-tests/tests/filter_tests/test_filters.rs
#	raphtory/src/db/graph/views/filter/model/exploded_edge_filter.rs
Comment thread raphtory-graphql/src/model/graph/filter_expr_input.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/builder.rs Outdated
A tree is the one form a filter takes; the typed expression structs that
stood between it and the ops repeated the tree once more under a trait of
their own. Each leaf now builds its own value op and the tree combines
them, so `CreateFilter` is implemented on `Expr<L>` itself and there is
no `CreateOp`. The in-process node-state column is a node term.

The builder follows: a view holds its `Layer`, a chain is consumed by the
term built from it, a property term keeps what it reads and builds the
form asked for, a right-hand side is anything `Into<Expr>`, and a view on
the graph is a `Chain<GraphLeaf>`. A GraphQL read is built with its views
rather than scoped afterwards, so nothing modifies a tree once built; the
`excludeLayer` view and the `views` on an endpoint read go, each being a
second spelling of another.

A tree built only from constants is refused as a filter, and an exploded
edge's `is_active`, `is_valid`, `is_deleted` and `is_self_loop` are
tested per update, as its properties are.
`and`, `or` and `not` of filtered graphs asked each leg only its filter
hooks. A window, `at`, `latest` or snapshot keeps its restriction in its
time semantics, not its hooks, so a time view under them restricted
nothing. Each leg is now asked its full check at the level it can drop
something, so time and layer views count as they do on their own.

`NotFilteredGraph` returns for the negation of a predicate. A view under
`not` stays refused: keeping the updates outside a view needs time
semantics the filtered graphs do not have yet.
`graph.filter` walks the tree: a view applies the graph's own view
methods in order, a predicate filters by its entity, and `and`, `or` and
`not` combine the graphs of their legs, each read on the same graph. The
split step, the view-chain structs, index pushdown and the streamed
aggregates go; pushdown and streaming return as optimisations later.

Work in progress: node and edge collections still go through
`graph.filter`, so an exploded collection answers per edge rather than
per update, and the cases waiting on a decision are ignored in the Rust
tests and not yet marked in the Python ones.
Comment thread raphtory/src/db/graph/views/filter/model/node_expr/typing.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/node_expr/typing.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/node_expr/typing.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
`graph.filter` reads each leg on the base graph, so the tests that pinned
a view applying first now pin that, and the user guide says so. Node and
edge collections test each of their items, so an exploded collection
answers per update again and a node made only by its edges is not lost.
A constant leg of a node `and` or `or` is refused on an edge collection
too, as everywhere else.

The cases that wait on a decision (a node and an edge leg combined, and
nodes that exist only through their edges on `graph.filter`) are kept as
expected failures with their reason.
A boolean is a `bool` everywhere: in names, docs, the GraphQL
descriptions and the error messages that said "yes/no". `any()` and
`all()` are qualifiers throughout, matching `qualified_type`, including
the GraphQL `qualified` field. The ops that `all_of` and `any_of` build
are named after them.
Comment thread raphtory-api/src/core/entities/layers.rs Outdated
Comment thread raphtory-graphql/src/model/graph/filter_expr_input.rs Outdated
Comment thread raphtory-graphql/src/model/graph/filter_expr_input.rs
Comment thread raphtory-graphql/src/model/graph/filter_expr_input.rs Outdated
Comment thread raphtory-graphql/src/model/graph/filter_expr_input.rs Outdated
Comment thread raphtory/src/db/graph/views/filter/model/node_state_filter.rs Outdated
Comment thread raphtory/src/python/filter/edge_expr.rs Outdated
Comment thread raphtory/src/python/filter/node_expr.rs Outdated

fn check_value(lhs: &Typed, v: &Prop) -> PyResult<()> {
validate_const_comparable(&static_type(lhs)?, Some(v))
validate_const_comparable(&lhs.prop_type(), Some(v))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we should have another think about this, do we want the early failure in rust as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'd keep it as is: Python refuses early where the type is known without a graph (fields, degree, is_*, booleans), with the same messages the engine gives at filter(..); Rust keeps infallible builders and reports at filter(..)?, since there the error is already one ? away and making eq/gt/… return Result would put a ? on every Rust filter.

//! again, module-qualified so `eval` rebuilds it after `import raphtory`.

use crate::{
db::graph::views::filter::model::{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This file needs significant work to simplify the implementation, a lot of it is reimplementing stuff that comes from Repr, also a lot of the extra traits ideally would also just delegate to an implementation of Repr wherever possible as that is our trait for defining the output of __repr__ in python.

Comment thread raphtory/src/python/filter/repr.rs Outdated
The per-node and per-edge tests a filter tree becomes live with the node
and edge expressions, and the type rules, which are about expressions in
general, move from `node_expr` to `expr`.
Every filter test now lives in raphtory-tests and goes through the
public API: the tests of the type rules check the same messages through
`graph.filter`, and the edge collection tests keep their public
assertions. Tests that repeated one already in raphtory-tests are
dropped.
An id equality or membership test should start from the nodes it names
rather than visit every node. The narrowing returns with the
optimisation work, so these fail until then.
Comment thread python/tests/test_base_install/test_graphql/test_component_filter.py Outdated
Comment thread raphtory/src/db/graph/views/filter/model/expr/compile.rs Outdated
Comment thread raphtory-tests/tests/filter_tests/expr_tree.rs Outdated
Comment thread raphtory-tests/tests/filter_tests/node_expr.rs Outdated
A view of every layer is a valid view that does nothing, so the builder
records it as given and the GraphQL encoding drops it, instead of
refusing it; excluding every layer is sent as no layers. A filter that
runs locally can always be sent, except one built from in-process state.

The GraphQL filter tests move to raphtory-graphql-tests and go through
the public encoding.
An empty view, an `and` or `or` with no legs, and a view under `or` or
`not` are each refused by one check that takes what it checks, used by
the compiler and by the GraphQL decoding alike, so an empty view or
combination from GraphQL is refused where it arrives.
The filter goes over the wire as its GraphQL input, which has its own
serde; nothing serialises the tree itself. Its serde, and the serde of
`Layer` it needed, go.
A filter's `and` and `or` build one graph over all their expressions,
which hold `Arc<[DynGraphArc]>` operands instead of nesting pairs; on a
collection they build one `all_of`/`any_of` op over the expressions'
values, as `and`/`or` inside an expression already did. A view
expression is validated where it arrives, GraphQL included.

Types are merged, not picked: a tree's partial type and the op's type
unify, and a tree knows more before it meets a graph, so Python refuses
more mistakes when they are written. The in-process node-state column
is the compiled op itself.

Display puts layer names in a list and views in round brackets; `repr`
goes through `Repr`; a compiled filter prints `<compiled>`. The word
for a part of a combination is "expression", and "operand" where the
part is a graph.

Nodes that exist only through their edges leave a filtered graph with
them; the tests say so instead of waiting on a fix.

This branch has not been deployed

No deployments
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.

Negating a view-scoped filter is a no-op; the intended semantics are undefined

2 participants