Repository navigation
Conversation
…oving string dispatch
…ps with direct methods on Id accepting T: Into<GID>
…ing NodeNameFilterBuilder/NodeTypeFilterBuilder
…ing NodeNameFilterBuilder/NodeTypeFilterBuilder
…rs (windowed + layered)
…_expr # Conflicts: # raphtory/src/db/graph/views/filter/model/node_expr.rs
…e full filter-building pipeline on each type
…ilder → TemporalProp/Quantified/Aggregated
…ilterOps from prelude
… to restore dot-syntax property filter ops
…e .property().temporal() instead
There was a problem hiding this comment.
⚠️ 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.
…tifier ops with list-aware aggregates
`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.
# 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
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.
`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.
|
|
||
| fn check_value(lhs: &Typed, v: &Prop) -> PyResult<()> { | ||
| validate_const_comparable(&static_type(lhs)?, Some(v)) | ||
| validate_const_comparable(&lhs.prop_type(), Some(v)) |
There was a problem hiding this comment.
we should have another think about this, do we want the early failure in rust as well?
There was a problem hiding this comment.
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::{ |
There was a problem hiding this comment.
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.
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.
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.
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'sterm through
src/dst); a literal is a constant you write; an aggregatecollapses a list (
sum,avg,min,max,first,last,len,earliest,latest); a view scopes what a term sees.The layers
filter/model/expr/mod.rsdefines the tree.FilterExprisnode/edge/explodedEdge/view/and/or/not; each entity leg is anExpr<Leaf>over that entity's own terms, so an edge-only operation inside a nodeexpression is a compile error.
expr/builder.rsis the typed API:NodeFilter,EdgeFilter,ExplodedEdgeFilter,GraphFilterand their methods build the tree,so Rust callers, Python and GraphQL meet in the same data.
expr/compile.rsturns atree 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 anop once.
expr/stream.rsevaluates aggregates andany/allover a history withoutcollecting it.
expr/split.rsrewrites a tree, before it is compiled, into theviews to apply and the two questions it answers (which nodes stay, which edges
stay);
compile.rscompiles that once for every place a filter runs.calls the Rust builder of the same name, running compiles the tree,
repr()printsit back. Comparisons, membership, presence
and
any()/all()are refused where they are written when the left side's type isknown statically.
@oneOfinput per entity (NodeExpr,EdgeExpr,ExplodedEdgeExpr), each declared plainly with the crate's derives and mirroring thecore 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, areadis 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
kindenum. The conversion toand from the tree is written once over a small per-entity trait. Every
filter(expr:)and
select(expr:)takes it. Policy placeholders (var,claim) areValuevariants.FilterExpr!variable. The one filter thatcannot travel,
by_state_column, raisesValueErrorat the client.Breaking changes
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.
applyViewsis removed everywhere, with theViewCollection,NodesViewCollectionand
GraphViewCollectioninputs.filter(expr: { view: [ …ViewOps… ] })does what theydid, in the same order; a predicate that narrowed a collection is
select(expr:). Theview operations now include
excludeNodes,subgraph,subgraphNodeTypesandvalid,so nothing the collections offered is lost. Two behaviours move with it:
layersandexcludeLayersname layers that must exist, whilevalidLayersandexcludeValidLayersignore unknown names as the collections did; andlatest: false-style no-ops are refused. A property, degree oris_activeterm readthrough 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()ornode_type()is keptfor display and ignored when the filter runs, since those fields have no time axis.
edgesholds edges andedges.explode()/explode_layers()hold exploded edges; both stay one type,Edges(andNestedEdges,EdgesWindowSet) in Python and GraphQL andRemoteEdges/RemoteNestedEdgesin the remote client.[]andselectasktheir question once per item of the collection they are called on: per edge on
edges, per exploded edge onedges.explode(), whatever kind of predicate theyare given. A view used as a predicate on
edgestherefore keeps wholeedges and never trims their updates;
graph.window(..).edgesis the form that does.sum(),avg(),min()andmax()require numeric elements, as before the tree;a string or boolean history is refused with the aggregate and element type named.
raphtory-graphql/resources/index.html) still issues the oldgrammar (
nodeFilter/edgeFilterwithwhere:) andapplyViews, so the UI testjobs 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, orgraph.filter.Semantics decided
graph.filtera view leg of the top-level
andapplies first and the other legs run inside it, theway
graph.window(..).filter(expr)does; nestedands flatten, and a view underoror
notis refused there, since no graph is "windowed or filtered" or "not windowed".On a collection (
nodes[..],edges[..],select) every leg is a test on thecollection'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, soedges[~Graph.window(0, 5)]is the edges that do not exist in the window. Two viewlegs 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.
Node.window(0, 5).property("score")isthe score inside the window and the node comes back unwindowed. In GraphQL the views sit
on the
readitself and apply in list order. The viewoperations are
window,at,after,before,latest,snapshotAt,snapshotLatest,layers,validLayers,defaultLayer,excludeLayers/excludeLayer,excludeValidLayers,shrinkStart,shrinkEnd,excludeNodes,subgraph,subgraphNodeTypesandvalid,each equal to the graph view method of the same name.
db_v4's family rule: numbers compare by value across widths, astring never compares with a number, nothing is converted.
is_indrops membersoutside 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.
Edge.window(0, 5).src().property("x")reads inside the window.
id field does.
and/or/vieware refused.predicate answers the second by "both ends stayed", an edge predicate keeps every
node.
and,orandnotcombine the answers question by question, soname == "b" | name == "c"keeps the edge b→c,~of a node predicate keeps thenodes that fail it and the edges between them, and
~(node & edge)negates eachanswer. An
orbetween 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 withor). A nodecollection refuses any filter that tests edges.
graph.edges[f]andgraph.filter(f).edgesagree for every tree.message says what to write instead: a history compared without
any/all, aqualifier on a single yes/no, an aggregate of a scalar,
is_someon a term thatalways has a value, two terms of different types.
Performance
any/allover a temporal history stream the history; nothing iscollected into a list first.
reads from is not re-boxed on every evaluation.
scanning (the Node ID and OR filter bug #2754 narrowing, carried onto expressions).
Follow-ups filed
add_edgevanish from views that hide all theiredges. Pre-existing core behaviour, found while checking the guide's examples.
(
Edge.filter(ExplodedEdge.property("p2") == 4).is_valid()).comparisons on edge metadata are type-checked when the filter is built.
Verification
raphtory+raphtory-graphql+raphtory-tests(merged withdb_v4at1d6ac0b32)test_filters670 scenarios,raphtory-graphql247test_base_install(incl. GraphQL e2e and local/remote parity)raphtory-authincl. the restriction-equivalence property tests;integration-tests111