Skip to content

Commit dc0940d

Browse files
authored
feat(graph): support variable-length paths (1..N) with capped unrolling; (#5)
1 parent 7fbdaa0 commit dc0940d

4 files changed

Lines changed: 92 additions & 12 deletions

File tree

python/DEVELOPMENT.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,40 @@
22

33
## Building the project
44

5-
This project is built with [maturin](https://github.com/PyO3/maturin).
5+
This project is built with [maturin](https://github.com/PyO3/maturin) and uses
6+
[uv](https://docs.astral.sh/uv/) to manage a local virtual environment.
67

7-
It can be built in development mode with:
8+
Recommended uv workflow:
89

910
```shell
11+
cd python
12+
uv venv --python 3.11 .venv
13+
source .venv/bin/activate
14+
uv pip install maturin[patchelf]
15+
uv pip install -e '.[tests]'
1016
maturin develop
1117
```
1218

13-
This builds the Rust native module in place. You will need to re-run this
14-
whenever you change the Rust code. But changing the Python code doesn't require
15-
re-building.
19+
Notes:
20+
- If another virtual environment is active, run `deactivate` first so uv binds to `.venv`.
21+
- After changing Rust code, re-run `maturin develop`. Pure-Python changes do not require rebuilds.
1622

1723
## Running tests
1824

19-
To run the tests, first install the test packages:
25+
You can run tests either via the Makefile (uses uv under the hood) or directly with uv.
26+
27+
Using Makefile (recommended):
2028

2129
```shell
22-
pip install '.[tests]'
30+
cd python
31+
make test
2332
```
2433

25-
then:
34+
Directly with uv:
2635

2736
```shell
28-
make test
37+
cd python
38+
uv run pytest -v python/python/tests
2939
```
3040

3141
To check the documentation examples, use
@@ -72,7 +82,7 @@ From now any, any attempt to commit, will first run the linters against the
7282
modified files:
7383

7484
```shell
75-
$ git commit -m"Changed some python files"
85+
$ git commit -m "Changed some python files"
7686
black....................................................................Passed
7787
isort (python)...........................................................Passed
7888
ruff.....................................................................Passed

python/python/tests/test_graph.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,6 @@ def test_two_hop_friends_of_friends(graph_env):
158158
assert set(data["c_id"]) == {4}
159159

160160

161-
@pytest.mark.xfail(reason="Variable-length path (*1..2) support pending in executor")
162161
def test_variable_length_path(graph_env):
163162
config, datasets, _ = graph_env
164163
query = CypherQuery(

rust/lance-graph/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//!
1212
//! - Cypher query parsing and AST representation
1313
//! - Graph pattern matching on columnar data
14-
//! - Property graph interpretation of Lance datasets
14+
//! - Property graph interpretation of Lance datasets
1515
//! - Translation to optimized SQL via DataFusion
1616
//! - Support for nodes, relationships, and properties
1717
//!
@@ -47,6 +47,9 @@ pub mod query_processor;
4747
pub mod semantic;
4848
pub mod source_catalog;
4949

50+
/// Maximum allowed hops for variable-length relationship expansion (e.g., *1..N)
51+
pub const MAX_VARIABLE_LENGTH_HOPS: u32 = 20;
52+
5053
pub use config::{GraphConfig, NodeMapping, RelationshipMapping};
5154
pub use error::{GraphError, Result};
5255
pub use query::CypherQuery;

rust/lance-graph/src/query.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,74 @@ impl CypherQuery {
759759
message: "Graph configuration is required for execution".to_string(),
760760
location: snafu::Location::new(file!(), line!(), column!()),
761761
})?;
762+
763+
// Handle single-segment variable-length paths by unrolling ranges (*1..N, capped)
764+
if path.segments.len() == 1 {
765+
if let Some(length_range) = &path.segments[0].relationship.length {
766+
let cap: u32 = crate::MAX_VARIABLE_LENGTH_HOPS;
767+
let min_len = length_range.min.unwrap_or(1).max(1);
768+
let max_len = length_range.max.unwrap_or(cap);
769+
770+
if min_len > max_len {
771+
return Err(GraphError::InvalidPattern {
772+
message: format!(
773+
"Invalid variable-length range: min {:?} greater than max {:?}",
774+
length_range.min, length_range.max
775+
),
776+
location: snafu::Location::new(file!(), line!(), column!()),
777+
});
778+
}
779+
780+
if max_len > cap {
781+
return Err(GraphError::UnsupportedFeature {
782+
feature: format!(
783+
"Variable-length paths with length > {} are not supported (got {:?}..{:?})",
784+
cap, length_range.min, length_range.max
785+
),
786+
location: snafu::Location::new(file!(), line!(), column!()),
787+
});
788+
}
789+
790+
use datafusion::dataframe::DataFrame;
791+
let mut union_df: Option<DataFrame> = None;
792+
793+
for hops in min_len..=max_len {
794+
// Build a fixed-length synthetic path by repeating the single segment
795+
let mut synthetic = crate::ast::PathPattern {
796+
start_node: path.start_node.clone(),
797+
segments: Vec::with_capacity(hops as usize),
798+
};
799+
800+
for i in 0..hops {
801+
let mut seg = path.segments[0].clone();
802+
// Drop variables to avoid alias collisions on repeated hops
803+
seg.relationship.variable = None;
804+
if (i + 1) < hops {
805+
seg.end_node.variable = None; // intermediate hop
806+
}
807+
// Clear length spec for this fixed hop
808+
seg.relationship.length = None;
809+
synthetic.segments.push(seg);
810+
}
811+
812+
let exec = PathExecutor::new(ctx, cfg, &synthetic)?;
813+
let mut df = exec.build_chain().await?;
814+
df = exec.apply_where(df, &self.ast)?;
815+
df = exec.apply_return(df, &self.ast)?;
816+
817+
union_df = Some(match union_df {
818+
Some(acc) => acc.union(df).map_err(|e| GraphError::PlanError {
819+
message: format!("Failed to UNION variable-length paths: {}", e),
820+
location: snafu::Location::new(file!(), line!(), column!()),
821+
})?,
822+
None => df,
823+
});
824+
}
825+
826+
return Ok(union_df);
827+
}
828+
}
829+
762830
let exec = PathExecutor::new(ctx, cfg, path)?;
763831
let df = exec.build_chain().await?;
764832
let df = exec.apply_where(df, &self.ast)?;

0 commit comments

Comments
 (0)