Skip to content

Type-safe filter expression API - #2643

Draft
shivamka1 wants to merge 136 commits into
db_v4from
filter_expr_wip
Draft

shivamka1 wants to merge 136 commits into
db_v4from
filter_expr_wip

Conversation

@shivamka1

@shivamka1 shivamka1 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Type-safe filter expression API

What this PR does

A filter is one thing everywhere: an expression tree. Rust compiles it, Python
holds it, GraphQL accepts it as FilterExpr, 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 (node: { property: { name, where } },
window: { …, expr }).

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 read
filter.Node.property("score") > 4                 # a constant on the right
filter.Node.degree() > filter.Node.in_degree()    # another expression on the right
filter.Node.window(0, 5).property("score") > 4    # read inside a window
filter(expr: { gt: { lhs: { read: { entity: NODE, views: [{ window: { start: 0, end: 5 } }],
                                    target: { property: "score" } } },
                     rhs: { const: { f64: 4.0 } } } })

The layers

  1. Rust: filter/model/tree.rs defines 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; the typed
    expression API (EntityExprFilterOps, EntityAggOps, view wrappers) is
    what it compiles to. The builder DSL is gone and the Rust suite runs on
    expressions.
  2. Python: a filter object holds the tree and nothing else. Building
    extends the tree, running compiles it, repr() prints it. Comparisons,
    membership and presence tests are checked where they are written when the
    left side's type is known statically.
  3. GraphQL: one input type per tree node (FilterExpr, Expr, Read,
    Scope, ViewOp, Target, Cmp, Membership). Every filter(expr:) and
    select(expr:) takes it; the view collections' nodeFilter/edgeFilter
    entries collapse into one filter entry. Policy placeholders ({"var"},
    {"claim"}) sit in the const and values slots.
  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#390: 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.
  • The bundled UI (raphtory-graphql/resources/index.html) still issues
    the old grammar, so the UI test jobs are red until the UI is rebuilt against
    schema.graphql. The change on the UI side is a handful of call sites and
    the playground example strings.
  • 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

  • A view leg of an and applies first and the other legs run inside it,
    the way graph.window(..).filter(expr) does; nested ands flatten. A view
    under or or not is refused when written (Python) and when compiled
    (server). Before this, a view inside any composition was silently dropped:
    window & pred returned pred alone.
  • Constants keep db_v4's family rule; numeric comparisons compare by
    value (a constant wider than the property's type no longer matches nothing).
    Bool constants are ordered. is_in drops members outside the family.
  • Endpoint reads keep the edge's views: Edge.window(0, 5).src().property("x")
    reads inside the window.
  • Empty and/or/view are refused; not is a complement.
  • Invalid aggregation chains fail early: at build time when the type is known
    statically, at apply time otherwise, with the same error locally and remotely.

Follow-ups folded in

  • Node-id filters resolve their domain through the storage index instead of
    scanning (the Node ID and OR filter bug #2754 narrowing, carried onto expressions).
  • sum declares the type it actually produces, so comparisons above the
    element's range are expressible.
  • Python keeps db_v4's type surface (filter.Node, Edge, ExplodedEdge,
    Graph as classes of static methods); the regenerated stubs are fully typed,
    and the stub generator no longer lets a star-import shadow a submodule's
    classes.
  • The filtering user guide is rewritten for the expression API, with examples
    that run.

Known engine bug found on the way (not fixed here)

graph.filter(node_expr).filter(<edge filter containing a not>) empties the
graph, and exclude_nodes(..).filter(~node_expr) loses every edge: the chained
NotFilteredGraph negates the node filtering it inherits. The tree's and
gives the right answer. The RBAC equivalence harness works around it with a
comment; the issue write-up has the repro and a suggested fix.

Verification

suite result
Rust: raphtory + raphtory-graphql + raphtory-tests all passing; test_filters 630 scenarios
Python test_base_install (incl. GraphQL e2e and local/remote parity) 3398 passed · 15 skipped · 9 xfailed
pometry-storage #390 against this head raphtory-auth 138 tests incl. the restriction-equivalence property tests; RBAC Python 345 passed · 1 xfailed
CI on this PR all 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: 7b2239a Previous: 9823ef7 Ratio
lotr_graph/num_edges 5 ns/iter (± 0) 0 ns/iter (± 0) +∞
lotr_graph/num_nodes 5 ns/iter (± 0) 1 ns/iter (± 0) 5
lotr_graph/graph_latest 3 ns/iter (± 0) 0 ns/iter (± 0) +∞
lotr_graph_materialise/materialize 7376762 ns/iter (± 259838) 1564816 ns/iter (± 35303) 4.71
lotr_graph_window_100/num_nodes 15 ns/iter (± 1) 5 ns/iter (± 0) 3
lotr_graph_window_100_materialise/materialize 7951537 ns/iter (± 339616) 1669150 ns/iter (± 10700) 4.76
lotr_graph_window_10_materialise/materialize 3078255 ns/iter (± 168682) 971980 ns/iter (± 4278) 3.17
lotr_graph_subgraph_10pc_materialise/materialize 2055639 ns/iter (± 99729) 334634 ns/iter (± 1287) 6.14
lotr_graph_subgraph_10pc_windowed_materialise/materialize 1150672 ns/iter (± 4029) 230399 ns/iter (± 2617) 4.99
lotr_graph_window_50_layered/has_node_existing 335 ns/iter (± 20) 129 ns/iter (± 12) 2.60
lotr_graph_window_50_layered_materialise/materialize 29673365 ns/iter (± 1919660) 3488825 ns/iter (± 24948) 8.51
lotr_graph_persistent_window_50_layered/num_edges_temporal 527496 ns/iter (± 9622) 192686 ns/iter (± 1569) 2.74
lotr_graph_persistent_window_50_layered/has_node_existing 414 ns/iter (± 396) 174 ns/iter (± 83) 2.38
lotr_graph_persistent_window_50_layered/iterate_exploded_edges 3478723 ns/iter (± 292828) 1659940 ns/iter (± 19402) 2.10
lotr_graph_persistent_window_50_layered_materialise/materialize 52767497 ns/iter (± 3951732) 5298035 ns/iter (± 147912) 9.96

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

- edge expressions build their ops from the pre-transformed view,
  matching the node side and the CreateFilter contract; the orphaned
  boolean/set ops are removed
- constant comparison operands are validated by value castability,
  matching the runtime coercion; type-level compatibility remains for
  expression-vs-expression comparisons
- two tests pinning pre-db_v4 semantics (windowed is_self_loop, agg
  overflow) yield to their filter_tests twins
- the node_expr unit tests compile against the current API
- unused imports left behind by the bridge are removed
Coercing a concrete wrapper (Latest<T>, SnapshotAt<T>, ...) into
Arc<dyn DynEdgeFilterFactory> materialises a vtable whose own wrap
methods coerce deeper wrappers, so monomorphisation never terminates;
the compiler gave up 40 windows deep (E0275) once anything compiled
these bindings, which is exactly what cargo test --all-features does.

The four wrap methods now erase self before wrapping (the same trick
dyn_window already used), closing the set over wrapper-of-erased types.
The erased factory picks up the traits those wrappers need through the
existing Arc blankets: DynEntityExpr and DynCreateView become
supertraits, EdgeFilterFactory is implemented directly, and the
blanket's unused EdgeViewFilterOps bound is dropped.
Section markers drop their provenance notes; the FilterValue alias
comment explains the two types' roles and that both leave with the
composite path.
First execution of the python bindings surfaced defects invisible to
compilation:

- the module registered classes while the entry points are instance
  methods; the module attributes are now ready-made root instances
- the erased wrap methods dispatched back into themselves through the
  vtable (an unconditional runtime loop); the window family constructs
  the wrapper over the erased factory directly with the same bounds
  clamping as ViewWrapOps, and the manual Arc impls dispatch through
  as_ref() so the blanket on Arc cannot self-select
- comparison and string operators accept plain python values as well as
  expressions (extracted as Prop constants)
- temporal() is exposed on PropertyExpr through the existing DynTemporal
- predicates return FilterExpr: comparisons wrap their CreateFilter
  impls, factory predicates route through Dyn{Node,Edge}ViewFilterOps
  instead of Scoped ops, and NodeWindow carries the NodeViewFilterOps
  bound
- the erased expression type forwards prop_type and nullable, so set
  coercion and build-time validation see real types instead of Empty

The filter test files use one construction idiom (attribute style,
matching the rust docs).
any() and all() on an expression now return the qualifier expressions
themselves instead of terminating in an implicit eq(true), so the
comparison written after them applies per element and the qualifiers
collapse the results (innermost list level first):

    NodeFilter.property("p").temporal().any().eq(7)

lowers to the same op chain as the trailing form. The pieces:

- CreateOp::create_qualified_{node,edge}_op separates leading
  qualifiers from the value expression (forwarded through the dyn
  layer); AnyExpr/AllExpr strip themselves, aggregates pass qualifiers
  through and apply per element
- the comparison, string and set filters lower a qualified lhs to the
  list-aware elementwise ops wrapped in the qualifier collapse chain,
  validating against the element type
- a bare qualifier used as a filter keeps its old meaning (elementwise
  eq(true) then collapse) through one marker-dispatching CreateFilter

Also: the node_type expression yields the storage's default type key
for untyped nodes, matching the composite type mask (negations now
include untyped nodes); node metadata lookups raise the metadata error;
endpoint and exploded-edge properties expose temporal(). Three tests
that had pinned the untyped-node divergence follow their filter_tests
twins.
The expression ops now report the types they actually produce, which
turns a family of silent no-matches into build-time errors and makes
the coercion rules uniform:

- id, name, node_type and degree lowerings carry their static types
  (ids take the graph's id dtype); temporal ops report List of the
  property dtype; aggregates report their output structurally (the
  innermost list level collapses, outer levels survive for pending
  qualifiers); Arc<dyn EdgeOp> and the endpoint bridge forward
  prop_type; edge metadata stays untyped since its runtime shape
  depends on the edge's layers
- multi-qualifier chains collapse in the written order: leading
  qualifiers read outermost-first, trailing ones innermost-first
- qualified is_some/is_none apply elementwise through the list-aware
  unary ops; aggregates validate against the qualified element type;
  qualifiers over a known scalar are errors
- ordering operators are rejected for map and list properties as well
  as booleans; string operators validate their constant operand; the
  edge set path coerces values like the node path; map constants
  compare structurally (partial schemas) but non-map constants against
  map properties are errors
- u64/i64 sums that overflow promote to Decimal and still compare
- python comparisons raise TypeError at the call site when the
  expression's type is statically known and the value cannot coerce;
  string operands are checked eagerly

The python filter tests, which had never been executable, are
adjudicated against these semantics: provably wrong hand-written
expectations follow the rust parity suite, error-message assertions
use the expression-path messages, and mistyped-operand cases build
their filters inside the raise assertions.
The wire schema is unchanged; the DynFilter conversion now builds
expression filters instead of the composite enums. Field conditions
reuse the existing where-translations and dispatch onto typed field
expressions, property condition trees walk onto the dyn expression
chain (wrappers extend the expression in written order, combinators
branch, leaves become predicates), views wrap the lowered filter, and
edge endpoints evaluate a full lowered node filter per edge through the
new EdgeEndpointNodeFilter adapter.

The composite conversions remain for the python remote client, which
still serialises filters through the tree representation.
Python filter objects now carry both the compiled expression and the
GraphQL wire tree, recorded at construction. The remote client reads the
carried tree; expressions with no wire form (an expression on the rhs of
a comparison) are rejected at the remote boundary with a clear error.

The client error envelope now surfaces each GraphQL error's message text
instead of the raw JSON object, so quotes inside diagnostics are no
longer escaped. Test expectations updated for expression-engine wording
and semantics: bool constants coerce to ints in comparisons, mistyped
is_in values are rejected eagerly, and nodes.filter() defers uniformly
for every filter kind — narrowing membership is spelled nodes[...] /
select (reverses the #2690 special case).
Composite filters no longer execute: the CreateFilter impls on the
composite enums, the GraphQL-to-composite conversions, and the
TryAsCompositeFilter export trait are gone. The enums survive as plain
data — the transportable wire descriptor inside FilterTree — and the
composite-to-GraphQL direction stays for client transport. Server
resolvers pass wire filters straight to the expression lowering.

Recording view chains at construction had inverted non-commuting chains
on the wire (window then latest arrived as latest then window). Both
wire builders now nest views in application order, with parity tests
covering both orders of window+latest at graph, node and edge scope.
The builder factories, ops traits, field builders, validation module and
leaf executors are gone, along with their prelude exports. The rust test
suite runs on the expression API — the builder half of the A/B test pair
is deleted, its two unique tests moved into the expression suite.

Aggregations over latest list-valued properties now work on the typed
rust surface, matching what python already exposed. The exploded-edge
expression filter now passes deletion events through, as the removed
executor did; persistent-graph histories pin the behaviour.
The upstream rewrite of the deleted builder ops stays deleted; the OrOp
and AndOp const-value fixes and their tests are kept, and the NodeOp
trait's const_value_in_domain now receives storage. Upstream's renamed
membership test matches the uniform filter semantics on this branch, and
its new collection-filter suites pass against the expression engine
unchanged.
Node-id equality and set membership resolve their evaluation domain
through the storage index instead of visiting every node, carrying the
narrowing #2754 added to the removed builder op onto expressions. A
constant whose type does not match the graph's id type keeps the full
domain rather than guessing, and every other predicate is unchanged.

A sum widens at runtime but declared its element type, so a constant
beyond that element's range was rejected before the sum ran — no
comparison above 255 was expressible on a list of u8. Sum now declares
what the evaluator produces; reductions returning an element keep the
element type.

The generated stubs and the docstrings pointing at them still named
builder classes that no longer exist.
@shivamka1 shivamka1 changed the title Filter expr wip Type-safe filter expression API Sep 4, 2026
A constant was accepted whenever it could be cast into the property's
type, so a numeric string or a bool compared against a number and a
string property compared against a decimal. Constants now follow the
rule the property filters already stated: numerics compare with one
another, everything else only with its own type.

Ordering returns to booleans, which are ordered. Set membership drops
members of a type the property can never equal rather than refusing the
filter — being absent from a set is an answer, where a comparison has
no answer to give.

Degree keeps casting its constant, so a count still accepts the string
form a wire query sends. That cast was previously skipped, leaving the
comparison to fall through to a variant ordering rather than comparing
counts at all.
The upstream rewrite of the deleted builder ops stays deleted; its
indexed node-type op is kept and now reaches the storage trait it needs.
The prelude carries both the expression factories and the new storage
arguments, and the node-type subgraph tests follow the assertion helper
to its new home.
filter.Node, Edge, ExplodedEdge and Graph are classes of static methods again,
with their view methods returning the view-scoped NodeFilter, EdgeFilter,
ExplodedEdgeFilter and GraphFilter; Expr and PropertyExpr are registered so
annotations and isinstance work. The stub generator no longer lets a
submodule's star import shadow the classes the submodule defines, every filter
method documents its arguments and return type, and the remote client's
refusal of expression-rhs filters is pinned by a test.

Also: wire field names are shared constants, the set-cast helper is fallible,
one GID conversion serves both the wire and domain narrowing, the
EntityExprBuilder marker is renamed PredicateLhs, and the explicit-false
not()/is_true()/is_false() aliases are gone in favour of eq(); not() is the
set complement everywhere, as in python.
FilterExpr prints the FilterTree it carries in the notation the composite
filters already use, or says it is local-only when there is none; layer
selections print as names rather than debug output.
Edge.window(0, 5).src().property("p") read the source node's property
outside the window: the endpoint was built from an unscoped node filter,
so the view chain that reached src()/dst() was dropped locally while the
server applied it. The endpoint now carries the same views onto the node
it reads from, and a parity test pins both sides agreeing.
A compiled comparison cast its constant to the property's type and, when
the cast did not fit, fell back to an ordering that only knows same-type
pairs, so a u64 above i64::MAX against an i64 property matched nothing.
Prop::compare and Prop::equals are what the property filters used: exact
through i128 and Decimal, f64 when a float is involved, structural for
everything else.
shivamka1 and others added 3 commits September 17, 2026 12:17
The expression tree is the single form of a filter: Rust compiles it, Python holds it, GraphQL accepts it as FilterExpr, the remote client sends it and permission grants store it. The per-entity GraphQL grammar and its lowering are removed; view collections take one filter entry; a view leg of an and applies first and composes only with and; numeric comparisons compare by value; endpoint reads keep the edge's views; the filtering user guide is rewritten.
Takes the hash-consistent property comparison (#2777) and the iterator
locking refactor (#2773). Every conflict was in code this branch removed
(the composite filters, the degree and property filter ops, the reverse
wire conversion) or in generated stubs; the branch's side is kept, the
filter set helpers use the new HashableProp, and the stubs are
regenerated.

from typing import *
from raphtory import *
from raphtory import (

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.

do we need to spell this out, it is going to be hard to maintain

with pytest.raises(
Exception,
match='Invalid filter: Filter value type does not match node ID type. Expected Str but got "U64"',
match=r"Invalid filter: value I64\(3\) of type I64 cannot be coerced to Str",

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 shouldn't have escape characters in the error message!

filter_expr = filter.Node.property("p20").temporal().all().starts_with("Gold")
result_ids = sorted(graph.filter(filter_expr).nodes.id)
expected_ids = ["3", "4"]
expected_ids = ["1", "3", "4"]

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.

why has this changed?

//! ```rust,ignore
//! NodeFilter.id() // Id — e.g. .eq(GID::Str("v1".into()))
//! NodeFilter.name() // Name — e.g. .eq("Alice")
//! NodeFilter.node_type() // Type — e.g. .is_some::<Prop>()

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.

what's up with the turbo fish?

impl<E: EntityExpr + Clone + Send + Sync + 'static> PredicateLhs for TemporalPropExpr<E> {}

impl<E: EntityExpr + Clone + Send + Sync + 'static> EntityAggOps for TemporalPropExpr<E> {
fn sum(self) -> SumExpr<Self> {

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.

These seem like they can be default-implemented?

Comment thread raphtory/src/db/graph/views/filter/model/node_expr/exprs.rs
if let Some(Some(value)) = right.const_value() {
if let Some(gid) = gid_for_id_lookup(id_type, &value) {
return Ok(Arc::new(IdDomainNodeOp {
gids: Arc::from([gid]),

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.

node GID-> VID mapping can happen here, no need to defer that to the call to domain

Comment thread raphtory/src/db/graph/views/filter/model/node_expr/filters.rs
Comment thread raphtory/src/db/graph/views/filter/model/node_expr/mod.rs
Comment thread raphtory/src/db/graph/views/filter/model/node_expr/mod.rs
type Output = Option<Prop>;

fn apply(&self, _storage: &GraphStorage, node: VID) -> Option<Prop> {
self.graph.node(node)?.properties().get_by_id(self.prop_id)

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.

graph.node() triggers an unnecessary clone of the graph (unfortunate consequence of the current api signature), (&&self.graph).node() probably does the trick (make sure the returned type is NodeView<&G>).

We should refactor the apis to return reference by default but that is out-of-scope of this PR.

type Output = Option<Prop>;

fn apply(&self, _storage: &GraphStorage, node: VID) -> Option<Prop> {
self.graph.node(node)?.metadata().get_by_id(self.prop_id)

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.

make sure this doesn't clone the graph arc.

// ─────────────────────────────────────────────────────────────────────────────

#[derive(Clone)]
pub(crate) struct WithPropType<T> {

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.

You can trivially implement the type correctly for all the operations that are currently wrapped by this. This isn't needed.

Comment thread raphtory/src/python/filter/edge_expr.rs
Comment thread raphtory/src/python/filter/exploded_edge_expr.rs
Comment thread raphtory/src/python/filter/node_expr.rs
Comment thread raphtory/src/python/filter/node_expr.rs
Comment thread raphtory/src/python/filter/node_expr.rs

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.

2 participants