crates/knut-graphdb/src/types.rs
//! The backend-neutral result + input types every [`crate::GraphDb`] op speaks in.
/// One node in a returned [`SubGraph`]. Backend-neutral: a stable `id`, a `label`,
/// the **raw** natural key the node carried (a Vessel's `mmsi`, an Aircraft's
/// `icao24`, a Company's `org_nr`), if any, and the node's **place** on the earth if
/// it had one. The consumer resolves that raw key to an object identity — this crate
/// does not depend on any ontology (that would be a cycle), so the resolution stays
/// on the consumer's side.
///
/// `Eq` is deliberately absent: `place` is a pair of `f64`. `PartialEq` is what every
/// consumer and test actually uses.
#[derive(Clone, Debug, PartialEq)]
pub struct GraphNode {
/// The backend's node id (FalkorDB `id()`, or the Cozo `id` column).
pub id: i64,
/// The node's primary label (`"Person"`, `"Vessel"`, …), or `""` if unlabelled.
pub label: String,
/// The raw natural-key string this node carried, pre-resolution (`None` if it
/// carried none of the known natural-key properties).
pub natural_key: Option<String>,
/// **Where this node IS** — `[lon, lat]` in WGS84 degrees, read off the node's own
/// `lon`/`lat` properties by [`crate::falkor::parse_batch`]. `None` for the vast
/// majority of nodes, which are not anywhere: a Person has no coordinate, a road
/// junction does.
///
/// Deliberately raw degrees and NOT a projected point: this crate must not own a
/// map projection, or a consumer's map and its graph would be projected by two
/// different pieces of code. The consumer projects with the one its map uses.
pub place: Option<[f64; 2]>,
}
impl GraphNode {
pub fn new(id: i64, label: impl Into<String>) -> Self {
Self {
id,
label: label.into(),
natural_key: None,
place: None,
}
}
#[must_use]
pub fn with_key(mut self, key: Option<String>) -> Self {
self.natural_key = key;
self
}
/// Attach the node's `[lon, lat]` place (WGS84 degrees).
#[must_use]
pub fn with_place(mut self, place: Option<[f64; 2]>) -> Self {
self.place = place;
self
}
}
/// One edge in a returned [`SubGraph`], referencing node **ids** (not indices), so
/// the result is order-independent — the consumer builds its own index mapping.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraphEdge {
pub src_id: i64,
pub dst_id: i64,
pub rel: String,
}
/// A sub-graph returned by any [`crate::GraphDb`] op — nodes plus id-referencing
/// edges. Deliberately not de-duplicated or sorted here; the consumer merges /
/// orders it for its own render layer.
///
/// `Eq` is absent because [`GraphNode::place`] is floating point; `PartialEq` is what
/// consumers and tests compare with.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SubGraph {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
/// A node to upsert (the load entry point). `key` is an optional natural key stored
/// as a property so a later scene can return it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpsertNode {
pub id: i64,
pub label: String,
pub key: Option<String>,
}
impl UpsertNode {
pub fn new(id: i64, label: impl Into<String>) -> Self {
Self {
id,
label: label.into(),
key: None,
}
}
#[must_use]
pub fn with_key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
}
/// An edge to upsert, referencing node ids.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpsertEdge {
pub src_id: i64,
pub dst_id: i64,
pub rel: String,
}
impl UpsertEdge {
pub fn new(src_id: i64, dst_id: i64, rel: impl Into<String>) -> Self {
Self {
src_id,
dst_id,
rel: rel.into(),
}
}
}
/// One graph's **catalogue + size**: its distinct node **labels**, **relationship
/// types**, **property keys**, and its total node/edge **counts**.
///
/// This is what [`crate::GraphDb::open`] answers — the "what is in here?" surface a
/// picker fills a schema pane from before anyone writes a query. It is
/// backend-neutral and carries no colours: colour is a RENDERER's concern and lives
/// with the renderer (see the crate docs).
///
/// A graph that does not exist is [`crate::GraphError::NotFound`], **never** an empty
/// `GraphSchema` — an empty catalogue means an empty graph, and the two must stay
/// distinguishable.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GraphSchema {
/// The graph this catalogue was read from.
pub graph: String,
/// Distinct node labels, sorted and de-duplicated.
pub labels: Vec<String>,
/// Distinct relationship types, sorted and de-duplicated.
pub relationship_types: Vec<String>,
/// Distinct property keys, sorted and de-duplicated. May be empty for a backend
/// with no property catalogue — that is not a failure.
pub property_keys: Vec<String>,
pub node_count: u64,
pub edge_count: u64,
}
impl GraphSchema {
/// Sort + de-duplicate every catalogue list, so the schema is deterministic
/// regardless of the order the engine returned its catalogue in.
#[must_use]
pub fn normalised(mut self) -> Self {
for v in [
&mut self.labels,
&mut self.relationship_types,
&mut self.property_keys,
] {
v.sort();
v.dedup();
}
self
}
/// Derive a catalogue from an already-read [`SubGraph`] — the honest schema a
/// backend with no catalogue procedures can still answer. Property keys are
/// unknowable from a rendered scene, so they stay empty.
#[must_use]
pub fn from_subgraph(graph: impl Into<String>, sub: &SubGraph) -> Self {
let labels: std::collections::BTreeSet<String> = sub
.nodes
.iter()
.filter(|n| !n.label.is_empty())
.map(|n| n.label.clone())
.collect();
let rels: std::collections::BTreeSet<String> = sub
.edges
.iter()
.filter(|e| !e.rel.is_empty())
.map(|e| e.rel.clone())
.collect();
Self {
graph: graph.into(),
labels: labels.into_iter().collect(),
relationship_types: rels.into_iter().collect(),
property_keys: Vec::new(),
node_count: sub.nodes.len() as u64,
edge_count: sub.edges.len() as u64,
}
}
}