diff --git a/docs/reference/graphql/graphql_API.md b/docs/reference/graphql/graphql_API.md index b052036515..6cdaecc700 100644 --- a/docs/reference/graphql/graphql_API.md +++ b/docs/reference/graphql/graphql_API.md @@ -95,60 +95,60 @@ Graph path relative to the root namespace. -vectoriseGraph -Boolean! +namespaces +CollectionOfNamespace! -Update graph query, has side effects to update graph state +Returns all namespaces using recursive search -Returns:: GqlMutableGraph +Returns:: List of namespaces on root -path -String! +namespace +Namespace! -Graph path relative to the root namespace. +Returns a specific namespace at a given path + +Returns:: Namespace or error if no namespace found -model -EmbeddingModel - - -Optional embedding model; defaults to OpenAI's standard model. - - +path +String! + -nodes -Template +root +Namespace! -Optional node-document template (which fields go into each node's text representation); defaults to the built-in template. +Returns root namespace + +Returns:: Root namespace -edges -Template +plugins +QueryPlugin! -Optional edge-document template; defaults to the built-in template. +Returns a plugin. -vectorisedGraph -VectorisedGraph +receiveGraph +String! -Create vectorised graph in the format used for queries +Encodes graph and returns as string. -Returns:: GqlVectorisedGraph +Returns:: Base64 url safe encoded string @@ -162,78 +162,78 @@ Graph path relative to the root namespace. -namespaces -CollectionOfNamespace! +version +String! -Returns all namespaces using recursive search - -Returns:: List of namespaces on root +Version string of the running `raphtory-graphql` server build. -namespace -Namespace! +vectoriseGraph +Boolean! -Returns a specific namespace at a given path +Update graph query, has side effects to update graph state -Returns:: Namespace or error if no namespace found +Returns:: GqlMutableGraph path String! - + + +Graph path relative to the root namespace. + + -root -Namespace! +model +EmbeddingModel -Returns root namespace - -Returns:: Root namespace +Optional embedding model; defaults to OpenAI's standard model. -plugins -QueryPlugin! +nodes +Template -Returns a plugin. +Optional node-document template (which fields go into each node's text representation); defaults to the built-in template. -receiveGraph -String! +edges +Template -Encodes graph and returns as string. - -Returns:: Base64 url safe encoded string +Optional edge-document template; defaults to the built-in template. -path -String! +vectorisedGraph +VectorisedGraph -Graph path relative to the root namespace. +Create vectorised graph in the format used for queries + +Returns:: GqlVectorisedGraph -version +path String! -Version string of the running `raphtory-graphql` server build. +Graph path relative to the root namespace. @@ -774,38 +774,6 @@ If true, replace any graph already at `newPath`. -createIndex -Boolean! - - -(Experimental) Creates search index. - - - - -path -String! - - -Graph path relative to the root namespace. - - - - -indexSpec -IndexSpecInput - - -Optional spec selecting which node/edge property fields to index. Omit to index a default set. - - - - -inRam -Boolean! - - - flush Boolean! @@ -2291,7 +2259,7 @@ on the first key break to the second, etc.). [EdgeSortBy!]! -Ordered list of sort keys. Each entry chooses exactly one of `src` / `dst` / `time` / `property`, with an optional `reverse: true` to flip order. +Ordered list of sort keys. Each entry chooses exactly one of `src` / `dst` / `neighbour` / `time` / `property`, with an optional `reverse: true` to flip order. @@ -3467,91 +3435,6 @@ remain in the view even if all their edges are filtered out. Composite edge filter (by property, layer, src/dst, etc.). - - - -getIndexSpec -IndexSpec! - - -(Experimental) Get index specification. - - - - -searchNodes -[Node!]! - - -(Experimental) Searches for nodes which match the given filter -expression. Uses Tantivy's exact search; requires the graph to have -been indexed. - - - - -filter -NodeFilter! - - -Composite node filter (by name, property, type, etc.). - - - - -limit -Int! - - -Maximum number of nodes to return. - - - - -offset -Int! - - -Number of matches to skip before returning results. - - - - -searchEdges -[Edge!]! - - -(Experimental) Searches the index for edges which match the given -filter expression. Uses Tantivy's exact search; requires the graph to -have been indexed. - - - - -filter -EdgeFilter! - - -Composite edge filter (by property, layer, src/dst, etc.). - - - - -limit -Int! - - -Maximum number of edges to return. - - - - -offset -Int! - - -Number of matches to skip before returning results. - @@ -4336,57 +4219,6 @@ Zero-based page number; multiplies `limit` to determine where to start (default -### IndexSpec - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
nodeMetadata[String!]! - -Returns node metadata. - -
nodeProperties[String!]! - -Returns node properties. - -
edgeMetadata[String!]! - -Returns edge metadata. - -
edgeProperties[String!]! - -Returns edge properties. - -
- ### Intervals Provides access to the intervals between temporal entries of an object. @@ -6740,7 +6572,7 @@ first key break to the second, etc.). [NodeSortBy!]! -Ordered list of sort keys. Each entry chooses exactly one of `id` / `time` / `property`, with an optional `reverse: true` to flip order. +Ordered list of sort keys. Each entry chooses exactly one of `id` / `name` / `type` / `time` / `property`, with an optional `reverse: true` to flip order. @@ -8146,6 +7978,11 @@ Number of updates recorded for this property in the current view. ### VectorSelection +A working set of documents / nodes / edges built up via similarity +searches on a `VectorisedGraph`. Selections are mutable: you can grow +them with more hops (`expand*`), dereference the contents (`nodes`, +`edges`, `getDocuments`), or start fresh with `emptySelection`. + @@ -8368,6 +8205,11 @@ Optional `{start, end}` to restrict matches to edges active in that interval. ### VectorisedGraph +A graph with embedded vector representations for its nodes and edges. +Exposes similarity search over documents, nodes, and edges, plus +selection building (`emptySelection`) and index maintenance +(`optimizeIndex`). +
@@ -8381,17 +8223,33 @@ Optional `{start, end}` to restrict matches to edges active in that interval. - + - + - + @@ -8423,7 +8281,13 @@ Optional `{start, end}` to restrict matches to entities active in that interval. - + @@ -8455,7 +8319,13 @@ Optional `{start, end}` to restrict matches to nodes active in that interval. - + @@ -8895,25 +8765,38 @@ Filter evaluated within the layer-restricted view. - + - + + + + + + @@ -9817,38 +9700,6 @@ Optional nested filter applied after the window restriction.
optimizeIndex Boolean! + +Rebuild (or incrementally update) the on-disk vector indexes for nodes +and edges so subsequent similarity searches hit the fresh embeddings. +Safe to call repeatedly; returns true on success. + +
emptySelection VectorSelection! + +Returns an empty selection of documents. + +
entitiesBySimilarity VectorSelection! + +Find the highest-scoring nodes *and* edges (mixed) by similarity to a +natural-language query. The query is embedded server-side and matched +against indexed entity vectors. + +
query
nodesBySimilarity VectorSelection! + +Find the highest-scoring nodes by similarity to a natural-language +query. The query is embedded server-side and matched against indexed +node vectors. + +
query
edgesBySimilarity VectorSelection! + +Find the highest-scoring edges by similarity to a natural-language +query. The query is embedded server-side and matched against indexed +edge vectors. + +
queryBoolean -Reverse order +Reverse order. Applies to the `time` / `property` keys; the node keys +(`src` / `dst` / `neighbour`) carry their own `reverse` inside the +nested `NodeSortBy` and ignore this flag.
srcBooleanNodeSortBy -Source node +Sort by the source node.
dstBooleanNodeSortBy + +Sort by the destination node. + +
neighbourNodeSortBy -Destination +Sort by the neighbour node: the endpoint that is NOT the node these +edges were traversed from (the destination for a graph-level edge +collection).
-### IndexSpecInput - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
nodePropsPropsInput! - -Node properties. - -
edgePropsPropsInput! - -Edge properties. - -
- ### InputEdge @@ -10363,6 +10214,24 @@ Reverse order Unique Id + + + + + + + + + + + @@ -11355,72 +11224,6 @@ Value.
nameBoolean + +Node name + +
typeBoolean + +Node type. Untyped nodes sort first (before any named type). +
-### PropsInput - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
allAllPropertySpec - -All properties and metadata. - -
someSomePropertySpec - -Some properties and metadata. - -
- -### SomePropertySpec - -SomePropertySpec object containing lists of metadata and property names. - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
metadata[String!]! - -List of metadata. - -
properties[String!]! - -List of properties. - -
- ### Template @@ -11786,43 +11589,6 @@ Alignment unit used to align window boundaries.
-### AllPropertySpec - - - - - - - - - - - - - - - - - - - - - - -
ValueDescription
ALL - -All properties and metadata. - -
ALL_METADATA - -All metadata. - -
ALL_PROPERTIES - -All properties. - -
- ### DegreeDirection Filters nodes by computed degree with a directional scope. diff --git a/python/python/raphtory/__init__.pyi b/python/python/raphtory/__init__.pyi index 75340b82c7..55bf7b2a68 100644 --- a/python/python/raphtory/__init__.pyi +++ b/python/python/raphtory/__init__.pyi @@ -69,6 +69,7 @@ __all__ = [ "algorithms", "graph_loader", "graph_gen", + "vectors", "node_state", "filter", "iterables", @@ -668,6 +669,26 @@ class GraphView(object): GraphView: The layered view """ + def vectorise( + self, + model: VectorCache, + nodes: bool | str = True, + edges: bool | str = True, + verbose: bool = False, + ) -> VectorisedGraph: + """ + Create a VectorisedGraph from the current graph. + + Args: + model (VectorCache): Cache wrapping the embedding model used to embed documents. + nodes (bool | str): Enable for nodes to be embedded, disable for nodes to not be embedded or specify a custom document property to use if a string is provided. Defaults to True. + edges (bool | str): Enable for edges to be embedded, disable for edges to not be embedded or specify a custom document property to use if a string is provided. Defaults to True. + verbose (bool): Enable to print logs reporting progress. Defaults to False. + + Returns: + VectorisedGraph: A VectorisedGraph with all the documents and their embeddings, with an initial empty selection. + """ + def window(self, start: TimeInput, end: TimeInput) -> GraphView: """ Create a view of the GraphView including all events between `start` (inclusive) and `end` (exclusive) diff --git a/python/python/raphtory/graphql/__init__.pyi b/python/python/raphtory/graphql/__init__.pyi index 28fdf429c7..21a3bdcf62 100644 --- a/python/python/raphtory/graphql/__init__.pyi +++ b/python/python/raphtory/graphql/__init__.pyi @@ -122,9 +122,39 @@ class GraphServer(object): RunningGraphServer: The running server """ - def turn_off_index(self) -> None: + def vectorise_all_graphs( + self, + embeddings: OpenAIEmbeddings, + nodes: bool | str = True, + edges: bool | str = True, + ) -> None: + """ + Vectorise all graphs in the server working directory. + + Arguments: + embeddings (OpenAIEmbeddings): the embeddings to use + nodes (bool | str): if nodes have to be embedded or not or the custom template to use if a str is provided. Defaults to True. + edges (bool | str): if edges have to be embedded or not or the custom template to use if a str is provided. Defaults to True. + + Returns: + None: """ - Turn off index for all graphs. + + def vectorise_graph( + self, + name: list[str], + embeddings: OpenAIEmbeddings, + nodes: bool | str = True, + edges: bool | str = True, + ) -> None: + """ + Vectorise the graph name in the server working directory. + + Arguments: + name (list[str]): the name of the graph to vectorise. + embeddings (OpenAIEmbeddings): the embeddings to use + nodes (bool | str): if nodes have to be embedded or not or the custom template to use if a str is provided. Defaults to True. + edges (bool | str): if edges have to be embedded or not or the custom template to use if a str is provided. Defaults to True. Returns: None: diff --git a/python/python/raphtory/node_state/__init__.pyi b/python/python/raphtory/node_state/__init__.pyi index 134a62396f..6e8c79273c 100644 --- a/python/python/raphtory/node_state/__init__.pyi +++ b/python/python/raphtory/node_state/__init__.pyi @@ -7818,6 +7818,17 @@ class OutputNodeState(object): Optional[dict]: the value for the node or the default value """ + def groups(self, cols: list[str]) -> list[tuple[dict, Nodes]]: + """ + Group by value + + Arguments: + cols (list[str]): columns by which to group nodes + + Returns: + list[tuple[dict, Nodes]]: The grouped nodes + """ + def items(self) -> Iterator[Tuple[Node, Dict]]: """ Iterate over items @@ -7854,6 +7865,17 @@ class OutputNodeState(object): Nodes: The nodes """ + def sort_by(self, sort_params: Dict) -> OutputNodeState: + """ + Get value for node + + Arguments: + sort_params (Dict): Map of sort keys to sort option ('asc' or 'desc'). None defaults to 'asc' + + Returns: + OutputNodeState: Sorted NodeState + """ + def to_parquet(self, file_path: str, id_column: str = "id") -> None: """ Convert OutputNodeState to Parquet @@ -7866,6 +7888,18 @@ class OutputNodeState(object): None: """ + def top_k(self, sort_params: Dict, k: int) -> OutputNodeState: + """ + Get value for node + + Arguments: + sort_params (Dict): Map of sort keys to sort option ('asc' or 'desc'). None defaults to 'asc' + k (int): Number of top entries to return. + + Returns: + OutputNodeState: Sorted NodeState + """ + def values(self) -> Iterator[Dict]: """ Iterate over values diff --git a/python/tests/test_base_install/test_graphql/edit_graph/test_graphql.py b/python/tests/test_base_install/test_graphql/edit_graph/test_graphql.py index 5fbd1882a5..cb057b4795 100644 --- a/python/tests/test_base_install/test_graphql/edit_graph/test_graphql.py +++ b/python/tests/test_base_install/test_graphql/edit_graph/test_graphql.py @@ -770,7 +770,7 @@ def test_graph_persistence_across_restarts(): # Verify persistence: check that nodes and edges are still there query_nodes = """{graph(path: "persistent_graph") {nodes {sorted (sortBys: [{id: true}]){ list {name} }}}}""" - query_edges = """{graph(path: "persistent_graph") {edges {sorted (sortBys: [{src: true, dst: true}]){ list {id} }}}}""" + query_edges = """{graph(path: "persistent_graph") {edges {sorted (sortBys: [{src: {id: true}, dst: {id: true}}]){ list {id} }}}}""" assert client.query(query_nodes) == { "graph": { diff --git a/python/tests/test_base_install/test_graphql/test_edge_sorting.py b/python/tests/test_base_install/test_graphql/test_edge_sorting.py index c4358c92de..aeb0325669 100644 --- a/python/tests/test_base_install/test_graphql/test_edge_sorting.py +++ b/python/tests/test_base_install/test_graphql/test_edge_sorting.py @@ -147,7 +147,7 @@ def test_graph_edge_sort_by_src(graph): query { graph(path: "g") { edges { - sorted(sortBys: [{ src: true }]) { + sorted(sortBys: [{ src: { id: true } }]) { list { src { id @@ -182,7 +182,7 @@ def test_graph_edge_sort_by_dst(graph): query { graph(path: "g") { edges { - sorted(sortBys: [{ dst: true }]) { + sorted(sortBys: [{ dst: { id: true } }]) { list { dst { id @@ -635,7 +635,7 @@ def test_graph_edge_sort_by_combined_2(graph): query { graph(path: "g") { edges { - sorted(sortBys: [{ dst: true }, { time: EARLIEST }, { property: "eprop3" }, { time: LATEST, reverse: true }]) { + sorted(sortBys: [{ dst: { id: true } }, { time: EARLIEST }, { property: "eprop3" }, { time: LATEST, reverse: true }]) { list { src { id diff --git a/raphtory-graphql/schema.graphql b/raphtory-graphql/schema.graphql index 77451b5703..b480914e2d 100644 --- a/raphtory-graphql/schema.graphql +++ b/raphtory-graphql/schema.graphql @@ -13,21 +13,6 @@ enum AlignmentUnit { YEAR } -enum AllPropertySpec { - """ - All properties and metadata. - """ - ALL - """ - All metadata. - """ - ALL_METADATA - """ - All properties. - """ - ALL_PROPERTIES -} - """ Collection of items """ @@ -746,17 +731,25 @@ type EdgeSchema { input EdgeSortBy { """ - Reverse order + Reverse order. Applies to the `time` / `property` keys; the node keys + (`src` / `dst` / `neighbour`) carry their own `reverse` inside the + nested `NodeSortBy` and ignore this flag. """ reverse: Boolean """ - Source node + Sort by the source node. + """ + src: NodeSortBy + """ + Sort by the destination node. """ - src: Boolean + dst: NodeSortBy """ - Destination + Sort by the neighbour node: the endpoint that is NOT the node these + edges were traversed from (the destination for a graph-level edge + collection). """ - dst: Boolean + neighbour: NodeSortBy """ Time """ @@ -1124,7 +1117,7 @@ type Edges { """ sorted( """ - Ordered list of sort keys. Each entry chooses exactly one of `src` / `dst` / `time` / `property`, with an optional `reverse: true` to flip order. + Ordered list of sort keys. Each entry chooses exactly one of `src` / `dst` / `neighbour` / `time` / `property`, with an optional `reverse: true` to flip order. """ sortBys: [EdgeSortBy!]! ): Edges! @@ -1815,48 +1808,6 @@ type Graph { expr: EdgeFilter! ): Graph! """ - (Experimental) Get index specification. - """ - getIndexSpec: IndexSpec! - """ - (Experimental) Searches for nodes which match the given filter - expression. Uses Tantivy's exact search; requires the graph to have - been indexed. - """ - searchNodes( - """ - Composite node filter (by name, property, type, etc.). - """ - filter: NodeFilter!, - """ - Maximum number of nodes to return. - """ - limit: Int!, - """ - Number of matches to skip before returning results. - """ - offset: Int! - ): [Node!]! - """ - (Experimental) Searches the index for edges which match the given - filter expression. Uses Tantivy's exact search; requires the graph to - have been indexed. - """ - searchEdges( - """ - Composite edge filter (by property, layer, src/dst, etc.). - """ - filter: EdgeFilter!, - """ - Maximum number of edges to return. - """ - limit: Int!, - """ - Number of matches to skip before returning results. - """ - offset: Int! - ): [Edge!]! - """ Apply a list of view operations in the given order and return the resulting graph view. Lets callers compose multiple view transforms (window, layer, filter, snapshot, ...) in a single call. @@ -2433,36 +2384,6 @@ type HistoryTimestamp { ): [Int!]! } -type IndexSpec { - """ - Returns node metadata. - """ - nodeMetadata: [String!]! - """ - Returns node properties. - """ - nodeProperties: [String!]! - """ - Returns edge metadata. - """ - edgeMetadata: [String!]! - """ - Returns edge properties. - """ - edgeProperties: [String!]! -} - -input IndexSpecInput { - """ - Node properties. - """ - nodeProps: PropsInput! - """ - Edge properties. - """ - edgeProps: PropsInput! -} - input InputEdge { """ Source node id (string or non-negative integer). @@ -2907,19 +2828,6 @@ type MutRoot { overwrite: Boolean! ): String! """ - (Experimental) Creates search index. - """ - createIndex( - """ - Graph path relative to the root namespace. - """ - path: String!, - """ - Optional spec selecting which node/edge property fields to index. Omit to index a default set. - """ - indexSpec: IndexSpecInput, inRam: Boolean! - ): Boolean! - """ Flush any pending writes for the graph at `graphPath` to disk. """ flush( @@ -3885,6 +3793,14 @@ input NodeSortBy { """ id: Boolean """ + Node name + """ + name: Boolean + """ + Node type. Untyped nodes sort first (before any named type). + """ + type: Boolean + """ Time """ time: SortByTime @@ -4250,7 +4166,7 @@ type Nodes { """ sorted( """ - Ordered list of sort keys. Each entry chooses exactly one of `id` / `time` / `property`, with an optional `reverse: true` to flip order. + Ordered list of sort keys. Each entry chooses exactly one of `id` / `name` / `type` / `time` / `property`, with an optional `reverse: true` to flip order. """ sortBys: [NodeSortBy!]! ): Nodes! @@ -5125,17 +5041,6 @@ type PropertyTuple { value: PropertyOutput! } -input PropsInput @oneOf { - """ - All properties and metadata. - """ - all: AllPropertySpec - """ - Some properties and metadata. - """ - some: SomePropertySpec -} - type QueryPlugin { NoOps: String! } @@ -5180,40 +5085,6 @@ type QueryRoot { path: String! ): MutableGraph! """ - Update graph query, has side effects to update graph state - - Returns:: GqlMutableGraph - """ - vectoriseGraph( - """ - Graph path relative to the root namespace. - """ - path: String!, - """ - Optional embedding model; defaults to OpenAI's standard model. - """ - model: EmbeddingModel, - """ - Optional node-document template (which fields go into each node's text representation); defaults to the built-in template. - """ - nodes: Template, - """ - Optional edge-document template; defaults to the built-in template. - """ - edges: Template - ): Boolean! - """ - Create vectorised graph in the format used for queries - - Returns:: GqlVectorisedGraph - """ - vectorisedGraph( - """ - Graph path relative to the root namespace. - """ - path: String! - ): VectorisedGraph - """ Returns all namespaces using recursive search Returns:: List of namespaces on root @@ -5250,6 +5121,40 @@ type QueryRoot { Version string of the running `raphtory-graphql` server build. """ version: String! + """ + Update graph query, has side effects to update graph state + + Returns:: GqlMutableGraph + """ + vectoriseGraph( + """ + Graph path relative to the root namespace. + """ + path: String!, + """ + Optional embedding model; defaults to OpenAI's standard model. + """ + model: EmbeddingModel, + """ + Optional node-document template (which fields go into each node's text representation); defaults to the built-in template. + """ + nodes: Template, + """ + Optional edge-document template; defaults to the built-in template. + """ + edges: Template + ): Boolean! + """ + Create vectorised graph in the format used for queries + + Returns:: GqlVectorisedGraph + """ + vectorisedGraph( + """ + Graph path relative to the root namespace. + """ + path: String! + ): VectorisedGraph } type ShortestPathOutput { @@ -5257,20 +5162,6 @@ type ShortestPathOutput { nodes: [String!]! } -""" -SomePropertySpec object containing lists of metadata and property names. -""" -input SomePropertySpec { - """ - List of metadata. - """ - metadata: [String!]! - """ - List of properties. - """ - properties: [String!]! -} - enum SortByTime { """ Latest time @@ -5512,6 +5403,12 @@ input Value @oneOf { decimal: String } +""" +A working set of documents / nodes / edges built up via similarity +searches on a `VectorisedGraph`. Selections are mutable: you can grow +them with more hops (`expand*`), dereference the contents (`nodes`, +`edges`, `getDocuments`), or start fresh with `emptySelection`. +""" type VectorSelection { """ Returns a list of nodes in the current selection. @@ -5618,9 +5515,28 @@ type VectorSelection { ): VectorSelection! } +""" +A graph with embedded vector representations for its nodes and edges. +Exposes similarity search over documents, nodes, and edges, plus +selection building (`emptySelection`) and index maintenance +(`optimizeIndex`). +""" type VectorisedGraph { + """ + Rebuild (or incrementally update) the on-disk vector indexes for nodes + and edges so subsequent similarity searches hit the fresh embeddings. + Safe to call repeatedly; returns true on success. + """ optimizeIndex: Boolean! + """ + Returns an empty selection of documents. + """ emptySelection: VectorSelection! + """ + Find the highest-scoring nodes *and* edges (mixed) by similarity to a + natural-language query. The query is embedded server-side and matched + against indexed entity vectors. + """ entitiesBySimilarity( """ Natural-language search string; embedded by the server. @@ -5635,6 +5551,11 @@ type VectorisedGraph { """ window: VectorisedGraphWindow ): VectorSelection! + """ + Find the highest-scoring nodes by similarity to a natural-language + query. The query is embedded server-side and matched against indexed + node vectors. + """ nodesBySimilarity( """ Natural-language search string; embedded by the server. @@ -5649,6 +5570,11 @@ type VectorisedGraph { """ window: VectorisedGraphWindow ): VectorSelection! + """ + Find the highest-scoring edges by similarity to a natural-language + query. The query is embedded server-side and matched against indexed + edge vectors. + """ edgesBySimilarity( """ Natural-language search string; embedded by the server. @@ -5720,3 +5646,4 @@ schema { query: QueryRoot mutation: MutRoot } + diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 0541e0cf96..27ae8f2ac6 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -3042,4 +3042,401 @@ mod graphql_test { // `flush_and_clear`, which would rewrite the sidecar and mask the bug. drop(session); } + + #[tokio::test] + async fn test_nodes_sorted_by_type_then_name() { + let g = Graph::new(); + g.add_node(1, "b", NO_PROPS, Some("Person"), None).unwrap(); + g.add_node(1, "a", NO_PROPS, Some("Person"), None).unwrap(); + g.add_node(1, "c", NO_PROPS, Some("Company"), None).unwrap(); + g.add_node(1, "d", NO_PROPS, None, None).unwrap(); // untyped + + let graph: MaterializedGraph = g.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // type ascending: None < "Company" < "Person"; then name ascending. + let query = r#" + { + graph(path: "g") { + nodes { + sorted(sortBys: [{ type: true }, { name: true }]) { + list { name } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "nodes": { "sorted": { "list": [ + { "name": "d" }, { "name": "c" }, { "name": "a" }, { "name": "b" } + ] } } } }) + ); + } + + #[tokio::test] + async fn test_nodes_sorted_by_type_reverse() { + let g = Graph::new(); + g.add_node(1, "b", NO_PROPS, Some("Person"), None).unwrap(); + g.add_node(1, "a", NO_PROPS, Some("Person"), None).unwrap(); + g.add_node(1, "c", NO_PROPS, Some("Company"), None).unwrap(); + g.add_node(1, "d", NO_PROPS, None, None).unwrap(); // untyped + + let graph: MaterializedGraph = g.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // type descending (reverse: true): "Person" > "Company" > None; then name ascending. + let query = r#" + { + graph(path: "g") { + nodes { + sorted(sortBys: [{ type: true, reverse: true }, { name: true }]) { + list { name } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "nodes": { "sorted": { "list": [ + { "name": "a" }, { "name": "b" }, { "name": "c" }, { "name": "d" } + ] } } } }) + ); + } + + #[tokio::test] + async fn test_nodes_sorted_by_id_regression() { + let g = Graph::new(); + g.add_node(1, "c", NO_PROPS, None, None).unwrap(); + g.add_node(1, "a", NO_PROPS, None, None).unwrap(); + g.add_node(1, "b", NO_PROPS, None, None).unwrap(); + + let graph: MaterializedGraph = g.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + nodes { sorted(sortBys: [{ id: true }]) { list { name } } } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "nodes": { "sorted": { "list": [ + { "name": "a" }, { "name": "b" }, { "name": "c" } + ] } } } }) + ); + } + + async fn neighbour_sort_setup(tmp: &std::path::Path) -> crate::test_support::TestSetup { + // Anchor "hub" with edges in BOTH directions to typed + untyped neighbours. + let g = Graph::new(); + g.add_node(1, "hub", NO_PROPS, None, None).unwrap(); + g.add_node(1, "x", [("score", 3i64)], Some("B"), None) + .unwrap(); + g.add_node(1, "y", [("score", 1i64)], Some("A"), None) + .unwrap(); + g.add_node(1, "w", [("score", 2i64)], Some("A"), None) + .unwrap(); + g.add_node(1, "z", [("score", 4i64)], None, None).unwrap(); // untyped + g.add_edge(10, "hub", "x", NO_PROPS, None).unwrap(); // out -> nbr x (B) + g.add_edge(11, "y", "hub", NO_PROPS, None).unwrap(); // in -> nbr y (A) + g.add_edge(12, "w", "hub", NO_PROPS, None).unwrap(); // in -> nbr w (A) + g.add_edge(13, "hub", "z", NO_PROPS, None).unwrap(); // out -> nbr z (untyped) + + let graph: MaterializedGraph = g.into(); + setup_with_graphs(&[("g", graph)], tmp).await + } + + #[tokio::test] + async fn test_edges_sorted_by_neighbour_type_then_name() { + let tmp_dir = tempdir().unwrap(); + let setup = neighbour_sort_setup(tmp_dir.path()).await; + + // type asc (None < "A" < "B"), then name asc: z(None), w(A), y(A), x(B) + let query = r#" + { + graph(path: "g") { + node(name: "hub") { + edges { + explodeLayers { + sorted(sortBys: [ + { neighbour: { type: true } }, + { neighbour: { name: true } }, + { time: LATEST } + ]) { + count + page(limit: 10) { nbr { name nodeType } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "node": { "edges": { "explodeLayers": { "sorted": { + "count": 4, + "page": [ + { "nbr": { "name": "z", "nodeType": null } }, + { "nbr": { "name": "w", "nodeType": "A" } }, + { "nbr": { "name": "y", "nodeType": "A" } }, + { "nbr": { "name": "x", "nodeType": "B" } } + ] + } } } } } }) + ); + } + + #[tokio::test] + async fn test_edges_sorted_by_neighbour_property() { + let tmp_dir = tempdir().unwrap(); + let setup = neighbour_sort_setup(tmp_dir.path()).await; + + // neighbour "score" ascending: y(1), w(2), x(3), z(4) + let query = r#" + { + graph(path: "g") { + node(name: "hub") { + edges { + explodeLayers { + sorted(sortBys: [{ neighbour: { property: "score" } }]) { + page(limit: 10) { nbr { name } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "node": { "edges": { "explodeLayers": { "sorted": { + "page": [ + { "nbr": { "name": "y" } }, { "nbr": { "name": "w" } }, + { "nbr": { "name": "x" } }, { "nbr": { "name": "z" } } + ] + } } } } } }) + ); + } + + #[tokio::test] + async fn test_graph_edges_neighbour_id_equals_dst() { + // At graph level every ref is Dir::Out, so neighbour == dst. + let tmp_dir = tempdir().unwrap(); + let setup = neighbour_sort_setup(tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + edges { + sorted(sortBys: [{ neighbour: { id: true } }]) { + page(limit: 10) { dst { name } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + // dst ids ascending: hub, hub, x, z + assert_eq!( + data, + json!({ "graph": { "edges": { "sorted": { + "page": [ + { "dst": { "name": "hub" } }, { "dst": { "name": "hub" } }, + { "dst": { "name": "x" } }, { "dst": { "name": "z" } } + ] + } } } }) + ); + } + + #[tokio::test] + async fn test_edges_sorted_by_neighbour_name_reverse() { + let tmp_dir = tempdir().unwrap(); + let setup = neighbour_sort_setup(tmp_dir.path()).await; + + // Neighbour name DESCENDING via the nested `reverse`; the outer + // `reverse: true` must be ignored (the neighbour arm early-returns). + // hub's neighbours are w, x, y, z -> descending: z, y, x, w. + let query = r#" + { + graph(path: "g") { + node(name: "hub") { + edges { + explodeLayers { + sorted(sortBys: [{ reverse: true, neighbour: { name: true, reverse: true } }]) { + page(limit: 10) { nbr { name } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "node": { "edges": { "explodeLayers": { "sorted": { + "page": [ + { "nbr": { "name": "z" } }, { "nbr": { "name": "y" } }, + { "nbr": { "name": "x" } }, { "nbr": { "name": "w" } } + ] + } } } } } }) + ); + } + + #[tokio::test] + async fn test_edges_sorted_by_neighbour_self_loop() { + // Self-loop hub->hub must resolve nbr to the anchor itself, sorting + // under its own type ("M"), not treated specially. + let g = Graph::new(); + g.add_node(1, "hub", NO_PROPS, Some("M"), None).unwrap(); + g.add_node(1, "a", NO_PROPS, Some("A"), None).unwrap(); + g.add_edge(10, "hub", "a", NO_PROPS, None).unwrap(); + g.add_edge(11, "hub", "hub", NO_PROPS, None).unwrap(); + + let graph: MaterializedGraph = g.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + node(name: "hub") { + edges { + explodeLayers { + sorted(sortBys: [{ neighbour: { type: true } }]) { + count + page(limit: 10) { nbr { name nodeType } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "node": { "edges": { "explodeLayers": { "sorted": { + "count": 2, + "page": [ + { "nbr": { "name": "a", "nodeType": "A" } }, + { "nbr": { "name": "hub", "nodeType": "M" } } + ] + } } } } } }) + ); + } + + #[tokio::test] + async fn test_edges_sorted_by_neighbour_type_reverse_untyped_last() { + let tmp_dir = tempdir().unwrap(); + let setup = neighbour_sort_setup(tmp_dir.path()).await; + + // neighbour type DESCENDING (untyped sorts last, not first), then + // neighbour name ascending to break the "A" tie: x(B), w(A), y(A), z(None) + let query = r#" + { + graph(path: "g") { + node(name: "hub") { + edges { + explodeLayers { + sorted(sortBys: [ + { neighbour: { type: true, reverse: true } }, + { neighbour: { name: true } } + ]) { + page(limit: 10) { nbr { name nodeType } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "node": { "edges": { "explodeLayers": { "sorted": { + "page": [ + { "nbr": { "name": "x", "nodeType": "B" } }, + { "nbr": { "name": "w", "nodeType": "A" } }, + { "nbr": { "name": "y", "nodeType": "A" } }, + { "nbr": { "name": "z", "nodeType": null } } + ] + } } } } } }) + ); + } + + #[tokio::test] + async fn test_graph_edges_sorted_by_src_node() { + // Sort a graph-level edge collection by the src node (type, then name). + // Enabled by making `src` take a NodeSortBy; a bare id flag could not + // express this. + let g = Graph::new(); + g.add_node(1, "a", NO_PROPS, Some("Z"), None).unwrap(); + g.add_node(1, "b", NO_PROPS, Some("A"), None).unwrap(); + g.add_node(1, "c", NO_PROPS, None, None).unwrap(); // untyped + g.add_node(1, "x", NO_PROPS, None, None).unwrap(); + g.add_edge(10, "a", "x", NO_PROPS, None).unwrap(); + g.add_edge(11, "b", "x", NO_PROPS, None).unwrap(); + g.add_edge(12, "c", "x", NO_PROPS, None).unwrap(); + + let graph: MaterializedGraph = g.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // src type ascending, untyped first: None(c) < "A"(b) < "Z"(a) + let query = r#" + { + graph(path: "g") { + edges { + sorted(sortBys: [{ src: { type: true } }, { src: { name: true } }]) { + page(limit: 10) { src { name nodeType } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + assert_eq!( + data, + json!({ "graph": { "edges": { "sorted": { + "page": [ + { "src": { "name": "c", "nodeType": null } }, + { "src": { "name": "b", "nodeType": "A" } }, + { "src": { "name": "a", "nodeType": "Z" } } + ] + } } } }) + ); + } } diff --git a/raphtory-graphql/src/model/graph/edges.rs b/raphtory-graphql/src/model/graph/edges.rs index 317a625d6d..0f7d6109a0 100644 --- a/raphtory-graphql/src/model/graph/edges.rs +++ b/raphtory-graphql/src/model/graph/edges.rs @@ -8,7 +8,7 @@ use crate::{ windowset::GqlEdgesWindowSet, GqlAlignmentUnit, WindowDuration, }, - sorting::{EdgeSortBy, SortByTime}, + sorting::{compare_node, EdgeSortBy, SortByTime}, }, rayon::blocking_compute, }; @@ -331,7 +331,7 @@ impl GqlEdges { async fn sorted( &self, #[graphql( - desc = "Ordered list of sort keys. Each entry chooses exactly one of `src` / `dst` / `time` / `property`, with an optional `reverse: true` to flip order." + desc = "Ordered list of sort keys. Each entry chooses exactly one of `src` / `dst` / `neighbour` / `time` / `property`, with an optional `reverse: true` to flip order." )] sort_bys: Vec, ) -> Self { @@ -345,11 +345,32 @@ impl GqlEdges { 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 { + // Node keys resolve their endpoint and delegate + // to `compare_node`, which applies the nested + // `NodeSortBy.reverse`; they return directly so + // the outer `reverse` below never double-negates. + if let Some(src_sort) = sort_by.src.as_ref() { + return compare_node( + &first_edge.src(), + &second_edge.src(), + src_sort, + ); + } + if let Some(dst_sort) = sort_by.dst.as_ref() { + return compare_node( + &first_edge.dst(), + &second_edge.dst(), + dst_sort, + ); + } + if let Some(neighbour_sort) = sort_by.neighbour.as_ref() { + return compare_node( + &first_edge.nbr(), + &second_edge.nbr(), + neighbour_sort, + ); + } + let ordering = if let Some(sort_by_time) = sort_by.time { let (first_time, second_time) = match sort_by_time { SortByTime::Latest => { (first_edge.latest_time(), second_edge.latest_time()) diff --git a/raphtory-graphql/src/model/graph/nodes.rs b/raphtory-graphql/src/model/graph/nodes.rs index 515f6a0c12..e989c2c467 100644 --- a/raphtory-graphql/src/model/graph/nodes.rs +++ b/raphtory-graphql/src/model/graph/nodes.rs @@ -8,7 +8,7 @@ use crate::{ windowset::GqlNodesWindowSet, GqlAlignmentUnit, WindowDuration, }, - sorting::{NodeSortBy, SortByTime}, + sorting::{compare_node, NodeSortBy}, }, rayon::blocking_compute, }; @@ -335,7 +335,7 @@ impl GqlNodes { async fn sorted( &self, #[graphql( - desc = "Ordered list of sort keys. Each entry chooses exactly one of `id` / `time` / `property`, with an optional `reverse: true` to flip order." + desc = "Ordered list of sort keys. Each entry chooses exactly one of `id` / `name` / `type` / `time` / `property`, with an optional `reverse: true` to flip order." )] sort_bys: Vec, ) -> Self { @@ -348,39 +348,8 @@ impl GqlNodes { sort_bys .iter() .fold(Ordering::Equal, |current_ordering, sort_by| { - current_ordering.then_with(|| { - let ordering = if sort_by.id == Some(true) { - first_node.id().partial_cmp(&second_node.id()) - } else if let Some(sort_by_time) = sort_by.time.as_ref() { - let (first_time, second_time) = match sort_by_time { - SortByTime::Latest => { - (first_node.latest_time(), second_node.latest_time()) - } - SortByTime::Earliest => ( - first_node.earliest_time(), - second_node.earliest_time(), - ), - }; - first_time.partial_cmp(&second_time) - } else if let Some(sort_by_property) = sort_by.property.as_ref() { - let first_prop_maybe = - first_node.properties().get(sort_by_property); - let second_prop_maybe = - second_node.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 - } - }) + current_ordering + .then_with(|| compare_node(first_node, second_node, sort_by)) }) }) .map(|node_view| node_view.node) diff --git a/raphtory-graphql/src/model/sorting.rs b/raphtory-graphql/src/model/sorting.rs index 406a83d904..9e711eb33a 100644 --- a/raphtory-graphql/src/model/sorting.rs +++ b/raphtory-graphql/src/model/sorting.rs @@ -1,13 +1,21 @@ use dynamic_graphql::{Enum, InputObject}; +use raphtory::{db::graph::node::NodeView, prelude::*}; +use std::cmp::Ordering; #[derive(InputObject, Clone, Debug, Eq, PartialEq)] pub struct EdgeSortBy { - /// Reverse order + /// Reverse order. Applies to the `time` / `property` keys; the node keys + /// (`src` / `dst` / `neighbour`) carry their own `reverse` inside the + /// nested `NodeSortBy` and ignore this flag. pub reverse: Option, - /// Source node - pub src: Option, - /// Destination - pub dst: Option, + /// Sort by the source node. + pub src: Option, + /// Sort by the destination node. + pub dst: Option, + /// Sort by the neighbour node: the endpoint that is NOT the node these + /// edges were traversed from (the destination for a graph-level edge + /// collection). + pub neighbour: Option, /// Time pub time: Option, /// Property @@ -20,6 +28,11 @@ pub struct NodeSortBy { pub reverse: Option, /// Unique Id pub id: Option, + /// Node name + pub name: Option, + /// Node type. Untyped nodes sort first (before any named type). + #[graphql(name = "type")] + pub type_: Option, /// Time pub time: Option, /// Property @@ -33,3 +46,37 @@ pub enum SortByTime { /// Earliest time Earliest, } + +/// Compare two nodes by a single `NodeSortBy` key, applying that key's +/// `reverse`. Returns `Ordering::Equal` when the key selects nothing or the +/// values are incomparable. Shared by node sorting and edge neighbour sorting. +pub(crate) fn compare_node<'graph, G: GraphViewOps<'graph>>( + a: &NodeView<'graph, G>, + b: &NodeView<'graph, G>, + sort_by: &NodeSortBy, +) -> Ordering { + let ordering = if sort_by.id == Some(true) { + a.id().partial_cmp(&b.id()) + } else if sort_by.name == Some(true) { + a.name().partial_cmp(&b.name()) + } else if sort_by.type_ == Some(true) { + a.node_type().partial_cmp(&b.node_type()) + } else if let Some(sort_by_time) = sort_by.time.as_ref() { + let (first, second) = match sort_by_time { + SortByTime::Latest => (a.latest_time(), b.latest_time()), + SortByTime::Earliest => (a.earliest_time(), b.earliest_time()), + }; + first.partial_cmp(&second) + } else if let Some(prop) = sort_by.property.as_ref() { + a.properties() + .get(prop) + .partial_cmp(&b.properties().get(prop)) + } else { + None + }; + match ordering { + Some(o) if sort_by.reverse == Some(true) => o.reverse(), + Some(o) => o, + None => Ordering::Equal, + } +}