Skip to content

Commit b8eeb1f

Browse files
committed
feat(graph-query): add edge querying endpoint and support for edge body fields
- Introduced a new endpoint `POST /v1/graph/query/edges` to query edges using a request shape similar to vertex queries. - Added support for edge schemas with body fields, returning relevant edge details including `cell_id`, `from_id`, `to_id`, `schema_id`, `edge_type`, and `body`. - Updated the HTTP API documentation to reflect the new edge query capabilities. - Enhanced the graph engine to handle edge lookups and validate edge schemas.
1 parent 70b9abc commit b8eeb1f

6 files changed

Lines changed: 282 additions & 7 deletions

File tree

HTTP_API.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,22 @@ Request shape is the same as `/v1/query` and returns matching graph vertices:
280280

281281
Request shape is the same as `/v1/query/explain`.
282282

283+
`POST /v1/graph/query/edges`
284+
285+
Query edge body cells using the same request shape as `/v1/query`:
286+
287+
```json
288+
{
289+
"schema_name": "my_edge",
290+
"selection": "(= weight 7u64)",
291+
"order_by_field": "weight",
292+
"limit": 20
293+
}
294+
```
295+
296+
- Edge query currently supports edge schemas with body fields.
297+
- Response includes `cell_id`, `from_id`, `to_id`, `schema_id`, `edge_type`, and `body`.
298+
283299
`POST /v1/graph/query/traverse`
284300

285301
Request:

crates/morpheus-http-client/src/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,21 @@ impl MorpheusHttpClient {
353353
parse_json(res).await
354354
}
355355

356+
pub async fn graph_query_edges(
357+
&self,
358+
req: &GraphLookupRequest,
359+
) -> Result<ApiResponse<Vec<GraphLookupEdgeDto>>, ClientError> {
360+
let url = self.endpoint("/v1/graph/query/edges")?;
361+
let res = self
362+
.client
363+
.post(url)
364+
.json(req)
365+
.send()
366+
.await
367+
.map_err(ClientError::http)?;
368+
parse_json(res).await
369+
}
370+
356371
pub async fn graph_query_traverse(
357372
&self,
358373
req: &GraphTraverseRequest,

crates/morpheus-http-client/src/types.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,16 @@ pub struct GraphTraverseResult {
272272
pub edges: Vec<GraphTraverseEdgeDto>,
273273
}
274274

275+
#[derive(Debug, Deserialize, Serialize)]
276+
pub struct GraphLookupEdgeDto {
277+
pub cell_id: String,
278+
pub from_id: String,
279+
pub to_id: String,
280+
pub schema_id: u32,
281+
pub edge_type: Value,
282+
pub body: Value,
283+
}
284+
275285
#[cfg(test)]
276286
mod tests {
277287
use serde_json::from_str;

src/graph/query.rs

Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,26 @@ use std::collections::{HashSet, VecDeque};
22

33
use neb::dovahkiin::expr::serde::Expr;
44
use neb::dovahkiin::integrated::lisp::parse_to_serde_expr;
5+
use neb::dovahkiin::types::Map;
56
use neb::index::ranged::tree::btree::Ordering;
67
use neb::query::planner::{ClauseOrderExplain, QueryPlanExplain};
7-
use neb::ram::cell::OwnedCell;
8-
use neb::ram::types::Id;
8+
use neb::ram::cell::{OwnedCell, ReadError};
9+
use neb::ram::types::{Id, OwnedMap, OwnedValue};
910

11+
use crate::graph::edge::directed::DirectedEdge;
12+
use crate::graph::edge::undirectd::UndirectedEdge;
1013
use crate::graph::edge::bilateral::BilateralEdge;
1114
use crate::graph::{EdgeDirection, EdgeType, GraphEngine};
15+
use crate::server::schema::GraphSchema;
1216

1317
#[derive(Debug)]
1418
pub enum GraphQueryError {
1519
InvalidSelectionExpr(String),
1620
InvalidOrdering(String),
1721
InvalidDirection(String),
22+
SchemaNotEdge(u32),
23+
SchemaRequiresEdgeBody(u32),
24+
InvalidEdgeCell(String),
1825
EdgeSchemasRequired,
1926
Internal(String),
2027
}
@@ -61,6 +68,16 @@ pub struct GraphTraverseResult {
6168
pub edges: Vec<GraphTraverseEdge>,
6269
}
6370

71+
#[derive(Debug, Clone)]
72+
pub struct GraphLookupEdge {
73+
pub id: Id,
74+
pub from_id: Id,
75+
pub to_id: Id,
76+
pub schema_id: u32,
77+
pub edge_type: EdgeType,
78+
pub body: OwnedMap,
79+
}
80+
6481
#[derive(Debug, Clone)]
6582
pub struct GraphQueryPlanExplain {
6683
pub disjunction: bool,
@@ -78,10 +95,7 @@ pub struct GraphClauseOrderExplain {
7895
}
7996

8097
impl GraphEngine {
81-
pub async fn lookup_vertex_ids(
82-
&self,
83-
req: &GraphLookupParams,
84-
) -> Result<Vec<Id>, GraphQueryError> {
98+
async fn lookup_ids(&self, req: &GraphLookupParams) -> Result<Vec<Id>, GraphQueryError> {
8599
let selection = parse_selection_expr(&req.selection)?;
86100
let ordering = parse_ordering(req.ordering.as_deref())?;
87101
let mut cursor = self
@@ -105,11 +119,18 @@ impl GraphEngine {
105119
Ok(ids)
106120
}
107121

122+
pub async fn lookup_vertex_ids(
123+
&self,
124+
req: &GraphLookupParams,
125+
) -> Result<Vec<Id>, GraphQueryError> {
126+
self.lookup_ids(req).await
127+
}
128+
108129
pub async fn lookup_vertices(
109130
&self,
110131
req: &GraphLookupParams,
111132
) -> Result<Vec<OwnedCell>, GraphQueryError> {
112-
let ids = self.lookup_vertex_ids(req).await?;
133+
let ids = self.lookup_ids(req).await?;
113134
let mut vertices = Vec::new();
114135
for id in ids {
115136
if let Some(vertex) = self.vertex_by(id).await.map_err(internal_error)? {
@@ -119,6 +140,72 @@ impl GraphEngine {
119140
Ok(vertices)
120141
}
121142

143+
pub async fn lookup_edges(
144+
&self,
145+
req: &GraphLookupParams,
146+
) -> Result<Vec<GraphLookupEdge>, GraphQueryError> {
147+
let edge_attrs = match self.schemas.schema_type(req.schema_id) {
148+
Some(GraphSchema::Edge(attrs)) => attrs,
149+
_ => return Err(GraphQueryError::SchemaNotEdge(req.schema_id)),
150+
};
151+
if !edge_attrs.has_body {
152+
return Err(GraphQueryError::SchemaRequiresEdgeBody(req.schema_id));
153+
}
154+
155+
let ids = self.lookup_ids(req).await?;
156+
let mut edges = Vec::new();
157+
for id in ids {
158+
let cell = match self.neb_client.read_cell(id).await {
159+
Err(e) => return Err(internal_error(e)),
160+
Ok(Err(ReadError::CellDoesNotExisted)) => continue,
161+
Ok(Err(e)) => return Err(internal_error(e)),
162+
Ok(Ok(cell)) => cell,
163+
};
164+
165+
let (from_field, to_field, edge_type) = match edge_attrs.edge_type {
166+
EdgeType::Directed => (
167+
DirectedEdge::edge_a_field(),
168+
DirectedEdge::edge_b_field(),
169+
EdgeType::Directed,
170+
),
171+
EdgeType::Undirected => (
172+
UndirectedEdge::edge_a_field(),
173+
UndirectedEdge::edge_b_field(),
174+
EdgeType::Undirected,
175+
),
176+
};
177+
178+
let body = match cell.data {
179+
OwnedValue::Map(m) => m,
180+
other => {
181+
return Err(GraphQueryError::InvalidEdgeCell(format!(
182+
"edge {id:?} body is not a map: {other:?}"
183+
)));
184+
}
185+
};
186+
187+
let (from_id, to_id) = match (body.get_by_key_id(from_field), body.get_by_key_id(to_field)) {
188+
(&OwnedValue::Id(from), &OwnedValue::Id(to)) => (from, to),
189+
(from, to) => {
190+
return Err(GraphQueryError::InvalidEdgeCell(format!(
191+
"edge {id:?} missing endpoint ids from fields {from_field}/{to_field}: {from:?}/{to:?}"
192+
)));
193+
}
194+
};
195+
196+
edges.push(GraphLookupEdge {
197+
id,
198+
from_id,
199+
to_id,
200+
schema_id: req.schema_id,
201+
edge_type,
202+
body,
203+
});
204+
}
205+
206+
Ok(edges)
207+
}
208+
122209
pub async fn lookup_explain_plan(
123210
&self,
124211
req: &GraphLookupExplainParams,
@@ -482,4 +569,100 @@ mod tests {
482569
assert!(out.contains(&ids[1]));
483570
assert!(out.contains(&ids[2]));
484571
}
572+
573+
async fn setup_edge_body_fixture(
574+
port: u32,
575+
group: &str,
576+
) -> (std::sync::Arc<crate::server::MorpheusServer>, u32, u32, Vec<Id>) {
577+
let server = start_server(port, group).await.expect("server should start");
578+
let graph: &GraphEngine = &server.graph;
579+
580+
let vertex_schema = MorpheusSchema::new("gq_vertex_for_edges", None, &EMPTY_FIELDS, true);
581+
let edge_schema = MorpheusSchema::new(
582+
"gq_edge_with_body",
583+
None,
584+
&vec![Field::new_indexed("weight", Type::U64, vec![IndexType::Hashed])],
585+
false,
586+
);
587+
let vertex_schema_id = graph
588+
.new_vertex_group(vertex_schema)
589+
.await
590+
.expect("vertex schema should be created");
591+
let edge_schema_id = graph
592+
.new_edge_group(edge_schema, EdgeAttributes::new(EdgeType::Directed, true))
593+
.await
594+
.expect("edge schema should be created");
595+
596+
let ids = vec![Id::new(0, 201), Id::new(0, 202), Id::new(0, 203)];
597+
for id in ids.iter().copied() {
598+
graph
599+
.new_vertex_with_id(vertex_schema_id, id, OwnedMap::new())
600+
.await
601+
.expect("vertex create");
602+
}
603+
604+
let mut body_a = OwnedMap::new();
605+
body_a.insert("weight", OwnedValue::U64(7));
606+
let body_a = Some(body_a);
607+
graph
608+
.link(ids[0], edge_schema_id, ids[1], &body_a)
609+
.await
610+
.expect("link tx 1")
611+
.expect("link 1");
612+
613+
let mut body_b = OwnedMap::new();
614+
body_b.insert("weight", OwnedValue::U64(9));
615+
let body_b = Some(body_b);
616+
graph
617+
.link(ids[1], edge_schema_id, ids[2], &body_b)
618+
.await
619+
.expect("link tx 2")
620+
.expect("link 2");
621+
622+
(server, vertex_schema_id, edge_schema_id, ids)
623+
}
624+
625+
#[tokio::test]
626+
async fn edge_lookup_supports_body_field_name_selection() {
627+
let (server, _vertex_schema_id, edge_schema_id, ids) =
628+
setup_edge_body_fixture(4045, "graph_query_edge_body_lookup").await;
629+
let graph: &GraphEngine = &server.graph;
630+
631+
let params = GraphLookupParams {
632+
schema_id: edge_schema_id,
633+
selection: "(= weight 7u64)".to_string(),
634+
ordering: None,
635+
order_by_field: None,
636+
limit: None,
637+
offset: None,
638+
};
639+
640+
let edges = graph.lookup_edges(&params).await.expect("edge lookup should succeed");
641+
assert_eq!(edges.len(), 1);
642+
assert_eq!(edges[0].from_id, ids[0]);
643+
assert_eq!(edges[0].to_id, ids[1]);
644+
assert!(matches!(edges[0].body.get("weight"), OwnedValue::U64(7)));
645+
}
646+
647+
#[tokio::test]
648+
async fn edge_lookup_rejects_non_body_edge_schema() {
649+
let (server, _vertex_schema_id, edge_schema_id, _ids) =
650+
setup_graph_fixture(4046, "graph_query_edge_non_body_reject", "score").await;
651+
let graph: &GraphEngine = &server.graph;
652+
653+
let params = GraphLookupParams {
654+
schema_id: edge_schema_id,
655+
selection: "(= score 20u64)".to_string(),
656+
ordering: None,
657+
order_by_field: None,
658+
limit: None,
659+
offset: None,
660+
};
661+
662+
let result = graph.lookup_edges(&params).await;
663+
assert!(matches!(
664+
result,
665+
Err(crate::graph::query::GraphQueryError::SchemaRequiresEdgeBody(_))
666+
));
667+
}
485668
}

src/http_gateway/graph_query.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::graph::query::{
1111
use super::cell_id::id_to_base58;
1212
use super::cells::CellDto;
1313
use super::types::{ApiError, ApiResponse};
14+
use super::value::HttpMap;
1415
use super::HttpState;
1516

1617
#[derive(Debug, Deserialize)]
@@ -131,6 +132,16 @@ pub struct GraphTraverseEdgeDto {
131132
pub edge_type: crate::graph::edge::EdgeType,
132133
}
133134

135+
#[derive(Debug, Serialize)]
136+
pub struct GraphLookupEdgeDto {
137+
pub cell_id: String,
138+
pub from_id: String,
139+
pub to_id: String,
140+
pub schema_id: u32,
141+
pub edge_type: crate::graph::edge::EdgeType,
142+
pub body: HttpMap,
143+
}
144+
134145
#[derive(Debug, Serialize)]
135146
pub struct QueryPlanExplainDto {
136147
pub disjunction: bool,
@@ -158,6 +169,17 @@ fn map_graph_query_error(err: GraphQueryError) -> ApiError {
158169
GraphQueryError::InvalidDirection(message) => {
159170
ApiError::bad_request("invalid_direction", message)
160171
}
172+
GraphQueryError::SchemaNotEdge(schema_id) => ApiError::bad_request(
173+
"schema_not_edge",
174+
format!("schema {schema_id} is not an edge schema"),
175+
),
176+
GraphQueryError::SchemaRequiresEdgeBody(schema_id) => ApiError::bad_request(
177+
"edge_schema_body_required",
178+
format!("edge schema {schema_id} has no body and cannot be queried by body fields"),
179+
),
180+
GraphQueryError::InvalidEdgeCell(message) => {
181+
ApiError::internal(format!("invalid edge cell: {message}"))
182+
}
161183
GraphQueryError::EdgeSchemasRequired => ApiError::bad_request(
162184
"edge_schemas_required",
163185
"at least one edge schema is required".to_string(),
@@ -232,6 +254,31 @@ pub async fn lookup_vertices(
232254
Ok(Json(ApiResponse::ok(vertices)))
233255
}
234256

257+
pub async fn lookup_edges(
258+
State(state): State<HttpState>,
259+
Json(req): Json<GraphLookupRequest>,
260+
) -> Result<Json<ApiResponse<Vec<GraphLookupEdgeDto>>>, ApiError> {
261+
let params = to_lookup_params(&state, &req)?;
262+
let edges = state
263+
.server
264+
.graph()
265+
.lookup_edges(&params)
266+
.await
267+
.map_err(map_graph_query_error)?;
268+
let data = edges
269+
.into_iter()
270+
.map(|edge| GraphLookupEdgeDto {
271+
cell_id: id_to_base58(&edge.id),
272+
from_id: id_to_base58(&edge.from_id),
273+
to_id: id_to_base58(&edge.to_id),
274+
schema_id: edge.schema_id,
275+
edge_type: edge.edge_type,
276+
body: HttpMap::from(edge.body),
277+
})
278+
.collect();
279+
Ok(Json(ApiResponse::ok(data)))
280+
}
281+
235282
pub async fn lookup_explain(
236283
State(state): State<HttpState>,
237284
Json(req): Json<GraphLookupExplainRequest>,

src/http_gateway/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ fn router(server: Arc<MorpheusServer>) -> Router {
7474
"/v1/graph/query/vertices/explain",
7575
axum::routing::post(graph_query::lookup_explain),
7676
)
77+
.route(
78+
"/v1/graph/query/edges",
79+
axum::routing::post(graph_query::lookup_edges),
80+
)
7781
.route(
7882
"/v1/graph/query/traverse",
7983
axum::routing::post(graph_query::traverse_from_lookup),

0 commit comments

Comments
 (0)