Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# SQLiteGraph Changelog

## [2.3.0] - 2026-05-15

### Added
- **`bfs_filtered` and `shortest_path_filtered`** — Kind-aware variants of `bfs` and `shortest_path`. Both accept `allowed_edge_types: &[&str]` to restrict traversal to specific edge types, mirroring the existing `k_hop_filtered` API. Free functions in `bfs.rs` (`bfs_neighbors_filtered`, `shortest_path_filtered`) plus `GraphBackend` trait methods and `&B` blanket forwarders.
- `bfs_neighbors_filtered(graph, start, max_depth, allowed_edge_types, direction)` — Outgoing or incoming traversal, restricted to allowed edge types. Empty `allowed_edge_types` returns an empty result (parity with `k_hop_filtered`).
- `shortest_path_filtered(graph, start, end, allowed_edge_types)` — BFS shortest path restricted to allowed edge types. Empty `allowed_edge_types` returns `None`.
- **Internal helpers `typed_adjacency` and `build_lookup`** in `multi_hop.rs` are now `pub(crate)` so `bfs.rs` can reuse the typed-edge SQL access path that already backs `k_hop_filtered`.

### Implementations
- **`SqliteGraphBackend`** — Full implementation of `bfs_filtered` and `shortest_path_filtered` via the new `bfs.rs` free functions. Backed by the existing typed-edge SQL access (`OUTGOING_TYPED_SQL` / `INCOMING_TYPED_SQL`).
- **`V3Backend`** — Stub implementation matching the existing `k_hop_filtered` pattern: delegates to the unfiltered methods until the V3 BFS path is wired to `edge_store.neighbors_filtered`. Tracked alongside the existing `k_hop_filtered` TODO.

### Tests
- **`bfs_tests.rs`** — 8 new integration tests covering filtered traversal: restriction to a single kind, union over multiple kinds, empty-allowlist returns empty, incoming direction, path selection through the allowed kind, exclusion when the only path uses an excluded kind, empty allowlist returns `None`, and the same-node singleton case.

## [2.2.5] - 2026-05-15

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion sqlitegraph-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "sqlitegraph"
version = "2.2.5"
version = "2.3.0"
edition = "2024"
description = "Embedded graph database with full ACID transactions, HNSW vector search, dual backend support, and comprehensive graph algorithms library"
license = "GPL-3.0"
Expand Down
36 changes: 36 additions & 0 deletions sqlitegraph-core/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,21 @@ pub trait GraphBackend {
direction: BackendDirection,
allowed_edge_types: &[&str],
) -> Result<Vec<i64>, SqliteGraphError>;
fn bfs_filtered(
&self,
snapshot_id: SnapshotId,
start: i64,
depth: u32,
direction: BackendDirection,
allowed_edge_types: &[&str],
) -> Result<Vec<i64>, SqliteGraphError>;
fn shortest_path_filtered(
&self,
snapshot_id: SnapshotId,
start: i64,
end: i64,
allowed_edge_types: &[&str],
) -> Result<Option<Vec<i64>>, SqliteGraphError>;
fn chain_query(
&self,
snapshot_id: SnapshotId,
Expand Down Expand Up @@ -606,6 +621,27 @@ where
(*self).k_hop_filtered(snapshot_id, start, depth, direction, allowed_edge_types)
}

fn bfs_filtered(
&self,
snapshot_id: SnapshotId,
start: i64,
depth: u32,
direction: BackendDirection,
allowed_edge_types: &[&str],
) -> Result<Vec<i64>, SqliteGraphError> {
(*self).bfs_filtered(snapshot_id, start, depth, direction, allowed_edge_types)
}

fn shortest_path_filtered(
&self,
snapshot_id: SnapshotId,
start: i64,
end: i64,
allowed_edge_types: &[&str],
) -> Result<Option<Vec<i64>>, SqliteGraphError> {
(*self).shortest_path_filtered(snapshot_id, start, end, allowed_edge_types)
}

fn chain_query(
&self,
snapshot_id: SnapshotId,
Expand Down
27 changes: 27 additions & 0 deletions sqlitegraph-core/src/backend/native/v3/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,33 @@ impl GraphBackend for V3Backend {
self.k_hop(_snapshot_id, _start, _depth, _direction)
}

fn bfs_filtered(
&self,
snapshot_id: SnapshotId,
start: i64,
depth: u32,
_direction: BackendDirection,
_allowed_edge_types: &[&str],
) -> Result<Vec<i64>, SqliteGraphError> {
// TODO: Implement edge type filtering for V3 backend.
// V3's edge_store exposes `neighbors_filtered`, but typed-edge traversal
// is not yet wired into the BFS path. Tracked alongside `k_hop_filtered`.
// For now, delegate to unfiltered bfs to match the existing stub pattern.
self.bfs(snapshot_id, start, depth)
}

fn shortest_path_filtered(
&self,
snapshot_id: SnapshotId,
start: i64,
end: i64,
_allowed_edge_types: &[&str],
) -> Result<Option<Vec<i64>>, SqliteGraphError> {
// TODO: Implement edge type filtering for V3 backend.
// See note on `bfs_filtered`.
self.shortest_path(snapshot_id, start, end)
}

fn chain_query(
&self,
_snapshot_id: SnapshotId,
Expand Down
25 changes: 24 additions & 1 deletion sqlitegraph-core/src/backend/sqlite/impl_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::{
SqliteGraphError,
backend::sqlite::types::{BackendDirection, EdgeSpec, NeighborQuery, NodeSpec},
backend::{PubSubEvent, SubscriptionFilter},
bfs::{bfs_neighbors, shortest_path},
bfs::{bfs_neighbors, bfs_neighbors_filtered, shortest_path, shortest_path_filtered},
graph::{GraphEdge, GraphEntity, SqliteGraph},
multi_hop,
pattern::{self, PatternMatch, PatternQuery},
Expand Down Expand Up @@ -427,6 +427,29 @@ impl crate::backend::GraphBackend for SqliteGraphBackend {
Ok(result)
}

fn bfs_filtered(
&self,
snapshot_id: crate::snapshot::SnapshotId,
start: i64,
depth: u32,
direction: BackendDirection,
allowed_edge_types: &[&str],
) -> Result<Vec<i64>, SqliteGraphError> {
validate_snapshot_for_sqlite(snapshot_id)?;
bfs_neighbors_filtered(&self.graph, start, depth, allowed_edge_types, direction)
}

fn shortest_path_filtered(
&self,
snapshot_id: crate::snapshot::SnapshotId,
start: i64,
end: i64,
allowed_edge_types: &[&str],
) -> Result<Option<Vec<i64>>, SqliteGraphError> {
validate_snapshot_for_sqlite(snapshot_id)?;
shortest_path_filtered(&self.graph, start, end, allowed_edge_types)
}

fn chain_query(
&self,
snapshot_id: crate::snapshot::SnapshotId,
Expand Down
93 changes: 92 additions & 1 deletion sqlitegraph-core/src/bfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ use std::collections::VecDeque;

use ahash::{AHashMap, AHashSet};

use crate::{errors::SqliteGraphError, graph::SqliteGraph};
use crate::{
backend::BackendDirection,
errors::SqliteGraphError,
graph::SqliteGraph,
multi_hop::{build_lookup, typed_adjacency},
};

pub fn bfs_neighbors(
graph: &SqliteGraph,
Expand All @@ -29,6 +34,37 @@ pub fn bfs_neighbors(
Ok(visited)
}

pub fn bfs_neighbors_filtered(
graph: &SqliteGraph,
start: i64,
max_depth: u32,
allowed_edge_types: &[&str],
direction: BackendDirection,
) -> Result<Vec<i64>, SqliteGraphError> {
graph.get_entity(start)?;
if allowed_edge_types.is_empty() {
return Ok(Vec::new());
}
let lookup = build_lookup(allowed_edge_types);
let mut visited = Vec::new();
let mut seen = AHashSet::new();
let mut queue = VecDeque::new();
queue.push_back((start, 0));
seen.insert(start);
while let Some((node, depth)) = queue.pop_front() {
visited.push(node);
if depth >= max_depth {
continue;
}
for next in typed_adjacency(graph, node, direction, Some(&lookup))? {
if seen.insert(next) {
queue.push_back((next, depth + 1));
}
}
}
Ok(visited)
}

pub fn shortest_path(
graph: &SqliteGraph,
start: i64,
Expand Down Expand Up @@ -78,3 +114,58 @@ pub fn shortest_path(
path.reverse();
Ok(Some(path))
}

pub fn shortest_path_filtered(
graph: &SqliteGraph,
start: i64,
end: i64,
allowed_edge_types: &[&str],
) -> Result<Option<Vec<i64>>, SqliteGraphError> {
graph.get_entity(start)?;
graph.get_entity(end)?;
if start == end {
return Ok(Some(vec![start]));
}
if allowed_edge_types.is_empty() {
return Ok(None);
}
let lookup = build_lookup(allowed_edge_types);
let mut queue = VecDeque::new();
let mut parents = AHashMap::new();
let mut seen = AHashSet::new();
queue.push_back(start);
seen.insert(start);
let mut found = false;
while let Some(node) = queue.pop_front() {
for next in typed_adjacency(graph, node, BackendDirection::Outgoing, Some(&lookup))? {
if seen.insert(next) {
parents.insert(next, node);
if next == end {
found = true;
break;
}
queue.push_back(next);
}
}
if found {
break;
}
}
if !found {
return Ok(None);
}
let mut path = vec![end];
let mut current = end;
while let Some(&parent) = parents.get(&current) {
path.push(parent);
if parent == start {
break;
}
current = parent;
}
if path.last().map(|last| *last != start).unwrap_or(true) {
return Ok(None);
}
path.reverse();
Ok(Some(path))
}
11 changes: 10 additions & 1 deletion sqlitegraph-core/src/multi_hop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ fn adjacency_for(
node: i64,
direction: BackendDirection,
allowed_types: Option<&AHashSet<&str>>,
) -> Result<Vec<i64>, SqliteGraphError> {
typed_adjacency(graph, node, direction, allowed_types)
}

pub(crate) fn typed_adjacency(
graph: &SqliteGraph,
node: i64,
direction: BackendDirection,
allowed_types: Option<&AHashSet<&str>>,
) -> Result<Vec<i64>, SqliteGraphError> {
match allowed_types {
Some(set) => filter_neighbors(graph, node, direction, set),
Expand Down Expand Up @@ -183,7 +192,7 @@ fn filter_neighbors(
Ok(result)
}

fn build_lookup<'a>(types: &'a [&'a str]) -> AHashSet<&'a str> {
pub(crate) fn build_lookup<'a>(types: &'a [&'a str]) -> AHashSet<&'a str> {
let mut lookup = AHashSet::with_capacity(types.len());
for ty in types {
lookup.insert(*ty);
Expand Down
Loading
Loading