use crate::errors::AppError;
use rusqlite::{params, Connection};
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalkDirection {
Directed,
Bidirectional,
}
#[derive(Debug, Clone)]
pub struct EdgeArrival {
pub from_id: i64,
pub neighbor_id: i64,
pub source_name: Option<String>,
pub target_name: Option<String>,
pub relation: String,
pub weight: f64,
pub inbound: bool,
}
#[derive(Debug, Clone)]
pub struct GraphWalk {
pub direction: WalkDirection,
pub weight_floor: Option<f64>,
pub max_hops: u32,
pub max_neighbors_per_hop: Option<usize>,
pub relation_filter: Option<String>,
}
impl GraphWalk {
#[must_use]
pub fn directed(min_weight: f64, max_hops: u32) -> Self {
Self {
direction: WalkDirection::Directed,
weight_floor: Some(min_weight),
max_hops,
max_neighbors_per_hop: None,
relation_filter: None,
}
}
#[must_use]
pub fn bidirectional(min_weight: f64, max_hops: u32) -> Self {
Self {
direction: WalkDirection::Bidirectional,
weight_floor: Some(min_weight),
max_hops,
max_neighbors_per_hop: None,
relation_filter: None,
}
}
#[must_use]
pub fn with_neighbor_cap(mut self, cap: Option<usize>) -> Self {
self.max_neighbors_per_hop = cap;
self
}
#[must_use]
pub fn with_relation_filter(mut self, relation: Option<String>) -> Self {
self.relation_filter = relation;
self
}
pub fn run<S: NeighborSource>(
&self,
source: &S,
seed_entity_ids: &[i64],
) -> Result<WalkOutcome, AppError> {
self.run_observed(source, seed_entity_ids, |_, _| {})
}
pub fn run_observed<S, F>(
&self,
source: &S,
seed_entity_ids: &[i64],
mut on_edge: F,
) -> Result<WalkOutcome, AppError>
where
S: NeighborSource,
F: FnMut(&EdgeArrival, u32),
{
let mut depth: HashMap<i64, u32> = seed_entity_ids.iter().map(|&id| (id, 0)).collect();
let mut arrival: HashMap<i64, EdgeArrival> = HashMap::new();
let mut expanded: HashSet<i64> = HashSet::with_capacity(depth.len());
let mut queue: VecDeque<i64> = seed_entity_ids.iter().copied().collect();
while let Some(current) = queue.pop_front() {
let current_depth = depth.get(¤t).copied().unwrap_or(0);
if current_depth >= self.max_hops || !expanded.insert(current) {
continue;
}
let next_depth = current_depth + 1;
let neighbors = source.neighbors(current, self)?;
let mut admitted = 0usize;
for edge in neighbors {
on_edge(&edge, next_depth);
if depth.contains_key(&edge.neighbor_id) {
continue;
}
if let Some(cap) = self.max_neighbors_per_hop {
if admitted >= cap {
continue;
}
}
admitted += 1;
depth.insert(edge.neighbor_id, next_depth);
queue.push_back(edge.neighbor_id);
arrival.insert(edge.neighbor_id, edge);
}
}
Ok(WalkOutcome { depth, arrival })
}
}
pub struct WalkOutcome {
pub depth: HashMap<i64, u32>,
pub arrival: HashMap<i64, EdgeArrival>,
}
pub trait NeighborSource {
fn neighbors(&self, entity_id: i64, walk: &GraphWalk) -> Result<Vec<EdgeArrival>, AppError>;
}
pub struct SqlNeighbors<'a> {
conn: &'a Connection,
namespace: &'a str,
with_names: bool,
}
impl<'a> SqlNeighbors<'a> {
#[must_use]
pub fn new(conn: &'a Connection, namespace: &'a str) -> Self {
Self {
conn,
namespace,
with_names: false,
}
}
#[must_use]
pub fn with_names(conn: &'a Connection, namespace: &'a str) -> Self {
Self {
conn,
namespace,
with_names: true,
}
}
fn query(
&self,
entity_id: i64,
walk: &GraphWalk,
inbound: bool,
) -> Result<Vec<EdgeArrival>, AppError> {
let pivot = if inbound { "target_id" } else { "source_id" };
let reached = if inbound { "source_id" } else { "target_id" };
let mut sql = if self.with_names {
format!(
"SELECT r.{reached}, se.name, te.name, r.relation, r.weight
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.{pivot} = ?1 AND r.weight >= ?2 AND r.namespace = ?3"
)
} else {
format!(
"SELECT r.{reached}, r.relation, r.weight FROM relationships r
WHERE r.{pivot} = ?1 AND r.weight >= ?2 AND r.namespace = ?3"
)
};
if walk.relation_filter.is_some() {
sql.push_str(" AND r.relation = ?4");
}
if walk.direction == WalkDirection::Directed {
sql.push_str(" ORDER BY r.weight DESC");
}
let floor = walk.weight_floor.unwrap_or(f64::NEG_INFINITY);
let mut stmt = self.conn.prepare_cached(&sql)?;
let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<EdgeArrival> {
let (neighbor_id, source_name, target_name, relation, weight) = if self.with_names {
(
row.get::<_, i64>(0)?,
Some(row.get::<_, String>(1)?),
Some(row.get::<_, String>(2)?),
row.get::<_, String>(3)?,
row.get::<_, f64>(4)?,
)
} else {
(
row.get::<_, i64>(0)?,
None,
None,
row.get::<_, String>(1)?,
row.get::<_, f64>(2)?,
)
};
Ok(EdgeArrival {
from_id: entity_id,
neighbor_id,
source_name,
target_name,
relation,
weight,
inbound,
})
};
let rows = match walk.relation_filter.as_deref() {
Some(rel) => stmt
.query_map(params![entity_id, floor, self.namespace, rel], map_row)?
.filter_map(std::result::Result::ok)
.collect(),
None => stmt
.query_map(params![entity_id, floor, self.namespace], map_row)?
.filter_map(std::result::Result::ok)
.collect(),
};
Ok(rows)
}
}
impl NeighborSource for SqlNeighbors<'_> {
fn neighbors(&self, entity_id: i64, walk: &GraphWalk) -> Result<Vec<EdgeArrival>, AppError> {
let mut out = self.query(entity_id, walk, false)?;
if walk.direction == WalkDirection::Bidirectional {
out.extend(self.query(entity_id, walk, true)?);
}
Ok(out)
}
}
#[derive(Debug, Clone)]
pub struct MemoryEdge {
pub source_id: i64,
pub target_id: i64,
pub relation: String,
pub weight: f64,
}
pub struct InMemoryNeighbors<'a> {
edges: &'a [MemoryEdge],
id_to_name: &'a HashMap<i64, String>,
}
impl<'a> InMemoryNeighbors<'a> {
#[must_use]
pub fn new(edges: &'a [MemoryEdge], id_to_name: &'a HashMap<i64, String>) -> Self {
Self { edges, id_to_name }
}
}
impl NeighborSource for InMemoryNeighbors<'_> {
fn neighbors(&self, entity_id: i64, walk: &GraphWalk) -> Result<Vec<EdgeArrival>, AppError> {
let floor = walk.weight_floor.unwrap_or(f64::NEG_INFINITY);
let mut out = Vec::with_capacity(8);
for edge in self.edges {
if edge.weight < floor {
continue;
}
if let Some(rel) = walk.relation_filter.as_deref() {
if edge.relation != rel {
continue;
}
}
let (neighbor_id, inbound) = if edge.source_id == entity_id {
(edge.target_id, false)
} else if edge.target_id == entity_id && walk.direction == WalkDirection::Bidirectional
{
(edge.source_id, true)
} else {
continue;
};
let Some(neighbor_name) = self.id_to_name.get(&neighbor_id) else {
continue;
};
let self_name = self.id_to_name.get(&entity_id).cloned();
let (source_name, target_name) = if inbound {
(Some(neighbor_name.clone()), self_name)
} else {
(self_name, Some(neighbor_name.clone()))
};
out.push(EdgeArrival {
from_id: entity_id,
neighbor_id,
source_name,
target_name,
relation: edge.relation.clone(),
weight: edge.weight,
inbound,
});
}
Ok(out)
}
}