diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d17f45..4452f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sqlitegraph-core/Cargo.toml b/sqlitegraph-core/Cargo.toml index c83c84e..a52a10a 100644 --- a/sqlitegraph-core/Cargo.toml +++ b/sqlitegraph-core/Cargo.toml @@ -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" diff --git a/sqlitegraph-core/src/backend.rs b/sqlitegraph-core/src/backend.rs index fef1ed9..c6394a5 100644 --- a/sqlitegraph-core/src/backend.rs +++ b/sqlitegraph-core/src/backend.rs @@ -250,6 +250,21 @@ pub trait GraphBackend { direction: BackendDirection, allowed_edge_types: &[&str], ) -> Result, SqliteGraphError>; + fn bfs_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + depth: u32, + direction: BackendDirection, + allowed_edge_types: &[&str], + ) -> Result, SqliteGraphError>; + fn shortest_path_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + end: i64, + allowed_edge_types: &[&str], + ) -> Result>, SqliteGraphError>; fn chain_query( &self, snapshot_id: SnapshotId, @@ -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, 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>, SqliteGraphError> { + (*self).shortest_path_filtered(snapshot_id, start, end, allowed_edge_types) + } + fn chain_query( &self, snapshot_id: SnapshotId, diff --git a/sqlitegraph-core/src/backend/native/v3/backend.rs b/sqlitegraph-core/src/backend/native/v3/backend.rs index 4e20966..c265f5a 100644 --- a/sqlitegraph-core/src/backend/native/v3/backend.rs +++ b/sqlitegraph-core/src/backend/native/v3/backend.rs @@ -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, 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>, 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, diff --git a/sqlitegraph-core/src/backend/sqlite/impl_.rs b/sqlitegraph-core/src/backend/sqlite/impl_.rs index 40311d9..9619a74 100644 --- a/sqlitegraph-core/src/backend/sqlite/impl_.rs +++ b/sqlitegraph-core/src/backend/sqlite/impl_.rs @@ -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}, @@ -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, 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>, 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, diff --git a/sqlitegraph-core/src/bfs.rs b/sqlitegraph-core/src/bfs.rs index 1377490..542feb9 100644 --- a/sqlitegraph-core/src/bfs.rs +++ b/sqlitegraph-core/src/bfs.rs @@ -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, @@ -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, 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, @@ -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>, 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(¤t) { + 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)) +} diff --git a/sqlitegraph-core/src/multi_hop.rs b/sqlitegraph-core/src/multi_hop.rs index 6e6c33f..a1f9417 100644 --- a/sqlitegraph-core/src/multi_hop.rs +++ b/sqlitegraph-core/src/multi_hop.rs @@ -139,6 +139,15 @@ fn adjacency_for( node: i64, direction: BackendDirection, allowed_types: Option<&AHashSet<&str>>, +) -> Result, 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, SqliteGraphError> { match allowed_types { Some(set) => filter_neighbors(graph, node, direction, set), @@ -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); diff --git a/sqlitegraph-core/tests/bfs_tests.rs b/sqlitegraph-core/tests/bfs_tests.rs index 42f2284..593f56e 100644 --- a/sqlitegraph-core/tests/bfs_tests.rs +++ b/sqlitegraph-core/tests/bfs_tests.rs @@ -1,7 +1,8 @@ use serde_json::json; use sqlitegraph::{ GraphEdge, GraphEntity, SqliteGraph, - bfs::{bfs_neighbors, shortest_path}, + backend::BackendDirection, + bfs::{bfs_neighbors, bfs_neighbors_filtered, shortest_path, shortest_path_filtered}, }; fn build_graph(edges: &[(i64, i64)]) -> SqliteGraph { @@ -32,6 +33,34 @@ fn build_graph(edges: &[(i64, i64)]) -> SqliteGraph { graph } +fn build_typed_graph(edges: &[(i64, i64, &str)]) -> SqliteGraph { + let graph = SqliteGraph::open_in_memory().expect("graph"); + for idx in 0..10 { + let name = format!("node_{idx}"); + graph + .insert_entity(&GraphEntity { + id: 0, + kind: "Node".to_string(), + name, + file_path: None, + data: json!({}), + }) + .unwrap(); + } + for &(from, to, edge_type) in edges { + graph + .insert_edge(&GraphEdge { + id: 0, + from_id: from, + to_id: to, + edge_type: edge_type.to_string(), + data: json!({}), + }) + .unwrap(); + } + graph +} + #[test] fn test_bfs_traversal_single_component() { let edges = vec![(1, 2), (2, 3), (3, 4), (4, 5)]; @@ -98,3 +127,100 @@ fn test_shortest_path_prefers_lexicographic_neighbors() { let path = shortest_path(&graph, 1, 4).expect("shortest"); assert_eq!(path, Some(vec![1, 2, 4])); } + +#[test] +fn test_bfs_neighbors_filtered_restricts_traversal_to_allowed_edge_types() { + let edges = vec![ + (1, 2, "CALL"), + (2, 3, "CALL"), + (1, 4, "IMPORTS"), + (4, 5, "IMPORTS"), + ]; + let graph = build_typed_graph(&edges); + let visited = + bfs_neighbors_filtered(&graph, 1, 10, &["CALL"], BackendDirection::Outgoing).expect("bfs"); + assert_eq!(visited, vec![1, 2, 3]); +} + +#[test] +fn test_bfs_neighbors_filtered_empty_allowed_types_returns_empty() { + let edges = vec![(1, 2, "CALL"), (2, 3, "CALL")]; + let graph = build_typed_graph(&edges); + let visited = + bfs_neighbors_filtered(&graph, 1, 10, &[], BackendDirection::Outgoing).expect("bfs"); + assert!(visited.is_empty()); +} + +#[test] +fn test_bfs_neighbors_filtered_multiple_kinds_unions_neighbors() { + let edges = vec![ + (1, 2, "CALL"), + (1, 3, "IMPORTS"), + (1, 4, "TESTS"), + (2, 5, "CALL"), + ]; + let graph = build_typed_graph(&edges); + let visited = bfs_neighbors_filtered( + &graph, + 1, + 10, + &["CALL", "IMPORTS"], + BackendDirection::Outgoing, + ) + .expect("bfs"); + assert!(visited.contains(&2)); + assert!(visited.contains(&3)); + assert!(visited.contains(&5)); + assert!(!visited.contains(&4)); +} + +#[test] +fn test_bfs_neighbors_filtered_incoming_direction() { + let edges = vec![(1, 5, "CALL"), (2, 5, "CALL"), (3, 5, "IMPORTS")]; + let graph = build_typed_graph(&edges); + let visited = + bfs_neighbors_filtered(&graph, 5, 10, &["CALL"], BackendDirection::Incoming).expect("bfs"); + assert!(visited.contains(&5)); + assert!(visited.contains(&1)); + assert!(visited.contains(&2)); + assert!(!visited.contains(&3)); +} + +#[test] +fn test_shortest_path_filtered_uses_only_allowed_kinds() { + let edges = vec![ + (1, 2, "CALL"), + (2, 3, "CALL"), + (1, 4, "IMPORTS"), + (4, 3, "IMPORTS"), + ]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 3, &["CALL"]).expect("shortest"); + assert_eq!(path, Some(vec![1, 2, 3])); + let path_imports = shortest_path_filtered(&graph, 1, 3, &["IMPORTS"]).expect("shortest"); + assert_eq!(path_imports, Some(vec![1, 4, 3])); +} + +#[test] +fn test_shortest_path_filtered_returns_none_when_kind_excludes_only_path() { + let edges = vec![(1, 2, "IMPORTS"), (2, 3, "IMPORTS")]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 3, &["CALL"]).expect("shortest"); + assert_eq!(path, None); +} + +#[test] +fn test_shortest_path_filtered_empty_allowed_types_returns_none() { + let edges = vec![(1, 2, "CALL"), (2, 3, "CALL")]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 3, &[]).expect("shortest"); + assert_eq!(path, None); +} + +#[test] +fn test_shortest_path_filtered_same_node_returns_singleton() { + let edges = vec![(1, 2, "CALL")]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 1, &["CALL"]).expect("shortest"); + assert_eq!(path, Some(vec![1])); +} diff --git a/sqlitegraph-py/CHANGELOG.md b/sqlitegraph-py/CHANGELOG.md index 8091bb7..8d8a8a2 100644 --- a/sqlitegraph-py/CHANGELOG.md +++ b/sqlitegraph-py/CHANGELOG.md @@ -3,6 +3,31 @@ This file tracks releases of the `sqlitegraph` package on PyPI. The Rust crate of the same name has its own changelog at the repository root. +## [0.2.0] - 2026-05-15 + +### Added +- **`bfs(start, depth, edge_types=None, direction=None)`** — `bfs` now accepts + an optional `edge_types` list and `direction` (`"outgoing"` or + `"incoming"`). When `edge_types` is provided, traversal only follows edges + whose type is in the list, dispatching to the new + `GraphBackend::bfs_filtered`. With `edge_types=None`, behavior is unchanged + (outgoing traversal, all edge types). +- **`shortest_path(start, end, edge_types=None)`** — Optional `edge_types` + list restricts which edge types the path can traverse, dispatching to + `GraphBackend::shortest_path_filtered`. Empty list returns `None`. +- **`k_hop(start, depth, direction=None, edge_types=None)`** — The existing + `k_hop` now exposes `edge_types`, dispatching to + `GraphBackend::k_hop_filtered` when provided. Empty list returns an empty + result. +- **11 new pytest tests** in `tests/test_filtered_traversal.py` covering each + new kwarg plus backwards-compatibility checks for the old kwargless calls. + +### Notes +- Built against `sqlitegraph` (Rust) **v2.3.0**, which adds the underlying + `bfs_filtered` / `shortest_path_filtered` trait methods. +- All existing tests continue to pass without modification — the new kwargs + are strictly additive. + ## [0.1.1] - 2026-05-15 ### Fixed diff --git a/sqlitegraph-py/Cargo.toml b/sqlitegraph-py/Cargo.toml index cbf9081..7c86a19 100644 --- a/sqlitegraph-py/Cargo.toml +++ b/sqlitegraph-py/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlitegraph-py" -version = "0.1.1" +version = "0.2.0" edition = "2021" description = "Python bindings for sqlitegraph via PyO3" license = "GPL-3.0-only" diff --git a/sqlitegraph-py/pyproject.toml b/sqlitegraph-py/pyproject.toml index 6b1e12f..1593780 100644 --- a/sqlitegraph-py/pyproject.toml +++ b/sqlitegraph-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "sqlitegraph" -version = "0.1.1" +version = "0.2.0" description = "Embedded graph database with HNSW vector search — Python bindings to the sqlitegraph Rust crate." license = { text = "GPL-3.0-only" } authors = [{ name = "Luiz Spies" }] diff --git a/sqlitegraph-py/src/lib.rs b/sqlitegraph-py/src/lib.rs index 0ec8047..4c2ecef 100644 --- a/sqlitegraph-py/src/lib.rs +++ b/sqlitegraph-py/src/lib.rs @@ -260,22 +260,73 @@ impl Graph { } /// BFS traversal from a node. - fn bfs(&self, start: i64, depth: u32) -> PyResult> { - self.backend - .bfs(sqlitegraph::SnapshotId::current(), start, depth) - .map_err(into_pyerr) + /// + /// Args: + /// start: Node ID to traverse from. + /// depth: Maximum hop distance. + /// edge_types: Optional list of edge types to traverse along. If + /// ``None``, every outgoing edge is followed. If provided as an + /// empty list, the result is empty. + /// direction: ``"outgoing"`` (default) or ``"incoming"``. Only meaningful + /// when ``edge_types`` is provided; the unfiltered path is always + /// outgoing. + #[pyo3(signature = (start, depth, edge_types=None, direction=None))] + fn bfs( + &self, + start: i64, + depth: u32, + edge_types: Option>, + direction: Option, + ) -> PyResult> { + let snapshot = sqlitegraph::SnapshotId::current(); + match edge_types { + None => self.backend.bfs(snapshot, start, depth).map_err(into_pyerr), + Some(types) => { + let dir = match direction.as_deref() { + Some("incoming") => sqlitegraph::BackendDirection::Incoming, + _ => sqlitegraph::BackendDirection::Outgoing, + }; + let refs: Vec<&str> = types.iter().map(String::as_str).collect(); + self.backend + .bfs_filtered(snapshot, start, depth, dir, &refs) + .map_err(into_pyerr) + } + } } /// K-hop neighbors. - #[pyo3(signature = (start, depth, direction=None))] - fn k_hop(&self, start: i64, depth: u32, direction: Option) -> PyResult> { + /// + /// Args: + /// start: Starting node ID. + /// depth: Number of hops. + /// direction: ``"outgoing"`` (default) or ``"incoming"``. + /// edge_types: Optional list of edge types to traverse along. When + /// provided as an empty list, the result is empty. + #[pyo3(signature = (start, depth, direction=None, edge_types=None))] + fn k_hop( + &self, + start: i64, + depth: u32, + direction: Option, + edge_types: Option>, + ) -> PyResult> { let dir = match direction.as_deref() { Some("incoming") => sqlitegraph::BackendDirection::Incoming, _ => sqlitegraph::BackendDirection::Outgoing, }; - self.backend - .k_hop(sqlitegraph::SnapshotId::current(), start, depth, dir) - .map_err(into_pyerr) + let snapshot = sqlitegraph::SnapshotId::current(); + match edge_types { + None => self + .backend + .k_hop(snapshot, start, depth, dir) + .map_err(into_pyerr), + Some(types) => { + let refs: Vec<&str> = types.iter().map(String::as_str).collect(); + self.backend + .k_hop_filtered(snapshot, start, depth, dir, &refs) + .map_err(into_pyerr) + } + } } /// Get node degree as ``(in_degree, out_degree)``. @@ -292,11 +343,33 @@ impl Graph { /// Shortest path between two nodes, as a list of node IDs. /// + /// Args: + /// start: Source node ID. + /// end: Destination node ID. + /// edge_types: Optional list of edge types the path may traverse. When + /// provided as an empty list, ``None`` is returned. + /// /// Returns ``None`` if no path exists. - fn shortest_path(&self, start: i64, end: i64) -> PyResult>> { - self.backend - .shortest_path(sqlitegraph::SnapshotId::current(), start, end) - .map_err(into_pyerr) + #[pyo3(signature = (start, end, edge_types=None))] + fn shortest_path( + &self, + start: i64, + end: i64, + edge_types: Option>, + ) -> PyResult>> { + let snapshot = sqlitegraph::SnapshotId::current(); + match edge_types { + None => self + .backend + .shortest_path(snapshot, start, end) + .map_err(into_pyerr), + Some(types) => { + let refs: Vec<&str> = types.iter().map(String::as_str).collect(); + self.backend + .shortest_path_filtered(snapshot, start, end, &refs) + .map_err(into_pyerr) + } + } } /// Fetch an edge by ID as a dict with keys: id, from_id, to_id, edge_type, data. diff --git a/sqlitegraph-py/tests/test_filtered_traversal.py b/sqlitegraph-py/tests/test_filtered_traversal.py new file mode 100644 index 0000000..479cb07 --- /dev/null +++ b/sqlitegraph-py/tests/test_filtered_traversal.py @@ -0,0 +1,113 @@ +"""Tests for kind-filtered graph traversal (bfs, shortest_path, k_hop).""" + +import sqlitegraph + + +def _g(): + return sqlitegraph.Graph.open_in_memory() + + +def _typed_chain(): + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + c = g.add_node(kind="N", name="c") + d = g.add_node(kind="N", name="d") + g.add_edge(a, b, "CALL") + g.add_edge(b, c, "CALL") + g.add_edge(a, d, "IMPORTS") + g.add_edge(d, c, "IMPORTS") + return g, a, b, c, d + + +def test_bfs_filter_allows_only_specified_edge_type(): + g, a, b, c, d = _typed_chain() + reached = g.bfs(a, depth=10, edge_types=["CALL"]) + assert a in reached + assert b in reached + assert c in reached + assert d not in reached + + +def test_bfs_filter_with_multiple_edge_types_unions_neighbors(): + g, a, b, c, d = _typed_chain() + reached = g.bfs(a, depth=10, edge_types=["CALL", "IMPORTS"]) + assert set(reached) >= {a, b, c, d} + + +def test_bfs_filter_empty_returns_only_start(): + g, a, _b, _c, _d = _typed_chain() + reached = g.bfs(a, depth=10, edge_types=[]) + assert reached == [] + + +def test_bfs_filter_incoming_direction(): + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + c = g.add_node(kind="N", name="c") + g.add_edge(a, c, "CALL") + g.add_edge(b, c, "IMPORTS") + reached = g.bfs(c, depth=10, edge_types=["CALL"], direction="incoming") + assert a in reached + assert b not in reached + + +def test_bfs_without_filter_still_works(): + """Backward compatibility: bfs() with no kwargs returns unfiltered result.""" + g, a, b, c, d = _typed_chain() + reached = g.bfs(a, depth=10) + assert set(reached) >= {a, b, c, d} + + +def test_shortest_path_filter_picks_path_through_allowed_kind(): + g, a, b, c, d = _typed_chain() + path = g.shortest_path(a, c, edge_types=["CALL"]) + assert path == [a, b, c] + assert d not in path + + path_imports = g.shortest_path(a, c, edge_types=["IMPORTS"]) + assert path_imports == [a, d, c] + assert b not in path_imports + + +def test_shortest_path_filter_returns_none_when_kind_excludes_all_paths(): + g, a, _b, c, _d = _typed_chain() + path = g.shortest_path(a, c, edge_types=["UNKNOWN_KIND"]) + assert path is None + + +def test_shortest_path_filter_empty_returns_none(): + g, a, _b, c, _d = _typed_chain() + path = g.shortest_path(a, c, edge_types=[]) + assert path is None + + +def test_shortest_path_without_filter_still_works(): + """Backward compatibility: shortest_path() with no kwargs returns unfiltered result.""" + g, a, _b, c, _d = _typed_chain() + path = g.shortest_path(a, c) + assert path is not None + assert path[0] == a and path[-1] == c + + +def test_k_hop_filter_excludes_neighbors_of_other_kinds(): + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + c = g.add_node(kind="N", name="c") + g.add_edge(a, b, "CALL") + g.add_edge(a, c, "IMPORTS") + hops = g.k_hop(a, depth=1, edge_types=["CALL"]) + assert b in hops + assert c not in hops + + +def test_k_hop_without_filter_still_works(): + """Backward compatibility: k_hop() with no edge_types returns unfiltered.""" + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + g.add_edge(a, b, "CALL") + hops = g.k_hop(a, depth=1) + assert b in hops