From edccce65c7785396511f91eb56326a3a882aab5a Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 17:13:46 +0800 Subject: [PATCH] GqlEdges sorting (#1906) * Add basic sorting (with reversing) functionality * Add basic property sorting * Format with nightly build * Add tests for edge sorting * Add sorting by src and dst --- python/tests/graphql/test_edge_sorting.py | 662 ++++++++++++++++++++++ raphtory-graphql/src/model/graph/edges.rs | 84 ++- raphtory/src/db/graph/edges.rs | 12 + 3 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 python/tests/graphql/test_edge_sorting.py diff --git a/python/tests/graphql/test_edge_sorting.py b/python/tests/graphql/test_edge_sorting.py new file mode 100644 index 000000000..8025a17b2 --- /dev/null +++ b/python/tests/graphql/test_edge_sorting.py @@ -0,0 +1,662 @@ +import tempfile + +import pytest + +from raphtory.graphql import GraphServer +from raphtory import Graph, PersistentGraph +import json +import re + +PORT = 1737 + + +def create_test_graph(g): + g.add_edge( + 3, + "a", + "d", + properties={ + "eprop1": 60, + "eprop2": 0.4, + "eprop3": "xyz123", + "eprop4": True, + "eprop5": [1, 2, 3], + }, + ) + g.add_edge( + 2, + "b", + "d", + properties={ + "eprop1": 10, + "eprop2": 1.7, + "eprop3": "xyz123", + "eprop4": True, + "eprop5": [3, 4, 5], + }, + ) + g.add_edge( + 1, + "c", + "d", + properties={ + "eprop1": 30, + "eprop2": 6.4, + "eprop3": "xyz123", + "eprop4": False, + "eprop5": [10], + }, + ) + g.add_edge( + 1, + "a", + "b", + properties={ + "eprop1": 80, + "eprop2": 3.3, + "eprop3": "xyz1234", + "eprop4": False, + }, + ) + g.add_edge( + 4, + "b", + "c", + properties={ + "eprop1": 100, + "eprop2": -2.3, + "eprop3": "ayz123", + "eprop5": [10, 20, 30], + }, + ) + return g + + +def run_graphql_test(query, expected_output, graph): + create_test_graph(graph) + tmp_work_dir = tempfile.mkdtemp() + with GraphServer(tmp_work_dir).start(PORT) as server: + client = server.get_client() + client.send_graph(path="g", graph=graph) + + response = client.query(query) + + # Convert response to a dictionary if needed and compare + response_dict = json.loads(response) if isinstance(response, str) else response + assert response_dict == expected_output + + +def run_graphql_error_test(query, expected_error_message, graph): + create_test_graph(graph) + tmp_work_dir = tempfile.mkdtemp() + with GraphServer(tmp_work_dir).start(PORT) as server: + client = server.get_client() + client.send_graph(path="g", graph=graph) + + with pytest.raises(Exception) as excinfo: + client.query(query) + + full_error_message = str(excinfo.value) + match = re.search(r'"message":"(.*?)"', full_error_message) + error_message = match.group(1) if match else "" + + assert ( + error_message == expected_error_message + ), f"Expected '{expected_error_message}', but got '{error_message}'" + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_nothing(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: []) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_src(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ src: true }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_dst(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ dst: true }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_earliest_time(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ time: EARLIEST }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_earliest_time_reversed(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ time: EARLIEST, reverse: true }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "b" }, "dst": { "id": "c" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + # C->D and A->B have the same time so will maintain their relative order + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph]) +def test_graph_edge_sort_by_latest_time(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ time: LATEST }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [PersistentGraph]) +def test_graph_edge_sort_by_latest_time_persistent_graph(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ time: LATEST }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + # In the persistent graph all edges will have the same latest_time + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_eprop1(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ property: "eprop1" }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_eprop2(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ property: "eprop2" }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "b" }, "dst": { "id": "c" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_eprop3(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ property: "eprop3" }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "b" }, "dst": { "id": "c" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_eprop4(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ property: "eprop4" }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "b" }, "dst": { "id": "c" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_eprop5(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ property: "eprop5" }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_nonexistent_prop(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ property: "i_dont_exist" }, { property: "eprop2", reverse: true }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_combined(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ property: "eprop3", reverse: true }, { property: "eprop4" }, { time: EARLIEST }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) + +@pytest.mark.parametrize("graph", [Graph, PersistentGraph]) +def test_graph_edge_sort_by_combined_2(graph): + query = """ + query { + graph(path: "g") { + edges { + sorted(sortBys: [{ dst: true }, { time: EARLIEST }, { property: "eprop3" }, { time: LATEST, reverse: true }]) { + list { + src { + id + } + dst { + id + } + } + } + } + } + } + """ + expected_output = { + "graph": { + "edges": { + "sorted": { + "list": [ + {"src": { "id": "a" }, "dst": { "id": "b" } }, + {"src": { "id": "b" }, "dst": { "id": "c" } }, + {"src": { "id": "c" }, "dst": { "id": "d" } }, + {"src": { "id": "b" }, "dst": { "id": "d" } }, + {"src": { "id": "a" }, "dst": { "id": "d" } }, + ] + } + } + } + } + run_graphql_test(query, expected_output, graph()) diff --git a/raphtory-graphql/src/model/graph/edges.rs b/raphtory-graphql/src/model/graph/edges.rs index 46b967909..1b19cf190 100644 --- a/raphtory-graphql/src/model/graph/edges.rs +++ b/raphtory-graphql/src/model/graph/edges.rs @@ -1,9 +1,15 @@ use crate::model::graph::edge::Edge; -use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; +use dynamic_graphql::{Enum, InputObject, ResolvedObject, ResolvedObjectFields}; +use itertools::Itertools; use raphtory::{ - db::{api::view::DynamicGraph, graph::edges::Edges}, - prelude::{EdgeViewOps, LayerOps, TimeOps}, + db::{ + api::view::{internal::OneHopFilter, DynamicGraph}, + graph::edges::Edges, + }, + prelude::{EdgeViewOps, LayerOps, NodeViewOps, TimeOps}, }; +use raphtory_api::iter::IntoDynBoxed; +use std::{cmp::Ordering, sync::Arc}; #[derive(ResolvedObject)] pub(crate) struct GqlEdges { @@ -27,6 +33,21 @@ impl GqlEdges { } } +#[derive(InputObject, Clone, Debug, Eq, PartialEq)] +pub struct EdgeSortBy { + pub reverse: Option, + pub src: Option, + pub dst: Option, + pub time: Option, + pub property: Option, +} + +#[derive(Enum, Clone, Debug, Eq, PartialEq)] +pub enum EdgeSortByTime { + Latest, + Earliest, +} + #[ResolvedObjectFields] impl GqlEdges { //////////////////////// @@ -95,6 +116,63 @@ impl GqlEdges { self.update(self.ee.explode_layers()) } + async fn sorted(&self, sort_bys: Vec) -> Self { + let sorted: Arc<[_]> = self + .ee + .iter() + .sorted_by(|first_edge, second_edge| { + sort_bys + .clone() + .into_iter() + .fold(Ordering::Equal, |current_ordering, sort_by| { + current_ordering.then_with(|| { + let ordering = if sort_by.src == Some(true) { + first_edge.src().id().partial_cmp(&second_edge.src().id()) + } else if sort_by.dst == Some(true) { + first_edge.dst().id().partial_cmp(&second_edge.dst().id()) + } else if let Some(sort_by_time) = sort_by.time { + let (first_time, second_time) = match sort_by_time { + EdgeSortByTime::Latest => { + (first_edge.latest_time(), second_edge.latest_time()) + } + EdgeSortByTime::Earliest => { + (first_edge.earliest_time(), second_edge.earliest_time()) + } + }; + first_time.partial_cmp(&second_time) + } else if let Some(sort_by_property) = sort_by.property { + let first_prop_maybe = + first_edge.properties().get(&*sort_by_property); + let second_prop_maybe = + second_edge.properties().get(&*sort_by_property); + first_prop_maybe.partial_cmp(&second_prop_maybe) + } else { + None + }; + if let Some(ordering) = ordering { + if sort_by.reverse == Some(true) { + ordering.reverse() + } else { + ordering + } + } else { + Ordering::Equal + } + }) + }) + }) + .map(|edge_view| edge_view.edge) + .collect(); + self.update(Edges::new( + self.ee.current_filter().clone(), + self.ee.base_graph().clone(), + Arc::new(move || { + let sorted = sorted.clone(); + (0..sorted.len()).map(move |i| sorted[i]).into_dyn_boxed() + }), + )) + } + //////////////////////// //// TIME QUERIES ////// //////////////////////// diff --git a/raphtory/src/db/graph/edges.rs b/raphtory/src/db/graph/edges.rs index 7407c972a..d1f61a15d 100644 --- a/raphtory/src/db/graph/edges.rs +++ b/raphtory/src/db/graph/edges.rs @@ -64,6 +64,18 @@ impl<'graph, G: GraphViewOps<'graph>, GH: GraphViewOps<'graph>> OneHopFilter<'gr } impl<'graph, G: GraphViewOps<'graph>, GH: GraphViewOps<'graph>> Edges<'graph, G, GH> { + pub fn new( + graph: GH, + base_graph: G, + edges: Arc BoxedLIter<'graph, EdgeRef> + Send + Sync + 'graph>, + ) -> Self { + Edges { + graph, + base_graph, + edges, + } + } + pub fn iter(&self) -> impl Iterator> + '_ { let base_graph = &self.base_graph; let graph = &self.graph;