Skip to content

Commit 554a1ed

Browse files
authored
Add support of the SKIP keyword (#11)
* feat(graph): support cypher skip keyword * docs(readme): update doc with skip support
1 parent 33e4fba commit 554a1ed

7 files changed

Lines changed: 344 additions & 16 deletions

File tree

rust/lance-graph/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,10 @@ A builder (`CypherQueryBuilder`) is also available for constructing queries prog
9898
- Node patterns `(:Label)` with optional variables.
9999
- Relationship patterns with fixed direction and type, including multi-hop paths.
100100
- Property comparisons against literal values with `AND`/`OR`/`NOT`/`EXISTS`.
101-
- RETURN lists of property accesses, optional `DISTINCT`, and `LIMIT`.
101+
- RETURN lists of property accesses, optional `DISTINCT`, `ORDER BY`, `SKIP` (offset), and `LIMIT`.
102102
- Positional and named parameters (e.g. `$min_age`).
103103

104-
Features such as ORDER BY, aggregations, optional matches, and subqueries are parsed but not executed yet.
104+
Features such as aggregations, optional matches, and subqueries are parsed but not executed yet.
105105

106106
## Crate Layout
107107

rust/lance-graph/src/ast.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pub struct CypherQuery {
2323
pub limit: Option<u64>,
2424
/// ORDER BY clause (optional)
2525
pub order_by: Option<OrderByClause>,
26+
/// SKIP/OFFSET clause (optional)
27+
pub skip: Option<u64>,
2628
}
2729

2830
impl CypherQuery {

rust/lance-graph/src/datafusion_planner.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ impl DataFusionPlanner {
146146
.build()
147147
.unwrap())
148148
}
149+
LogicalOperator::Offset { input, offset } => {
150+
let input_plan = self.plan_operator_with_ctx(input, var_labels)?;
151+
Ok(LogicalPlanBuilder::from(input_plan)
152+
.limit((*offset) as usize, None)
153+
.unwrap()
154+
.build()
155+
.unwrap())
156+
}
149157
LogicalOperator::Expand {
150158
input,
151159
source_variable,

rust/lance-graph/src/logical_plan.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ pub enum LogicalOperator {
7272
sort_items: Vec<SortItem>,
7373
},
7474

75+
/// Apply SKIP/OFFSET
76+
Offset {
77+
input: Box<LogicalOperator>,
78+
offset: u64,
79+
},
80+
7581
/// Apply LIMIT
7682
Limit {
7783
input: Box<LogicalOperator>,
@@ -147,6 +153,14 @@ impl LogicalPlanner {
147153
};
148154
}
149155

156+
// Apply SKIP/OFFSET if present
157+
if let Some(skip) = query.skip {
158+
plan = LogicalOperator::Offset {
159+
input: Box::new(plan),
160+
offset: skip,
161+
};
162+
}
163+
150164
// Apply LIMIT if present
151165
if let Some(limit) = query.limit {
152166
plan = LogicalOperator::Limit {
@@ -338,6 +352,7 @@ impl LogicalPlanner {
338352
LogicalOperator::Project { input, .. } => self.extract_variable_from_plan(input),
339353
LogicalOperator::Distinct { input } => self.extract_variable_from_plan(input),
340354
LogicalOperator::Sort { input, .. } => self.extract_variable_from_plan(input),
355+
LogicalOperator::Offset { input, .. } => self.extract_variable_from_plan(input),
341356
LogicalOperator::Limit { input, .. } => self.extract_variable_from_plan(input),
342357
LogicalOperator::Join { left, right, .. } => {
343358
// Prefer the right branch's tail variable, else fall back to left
@@ -796,6 +811,56 @@ mod tests {
796811
}
797812
}
798813

814+
#[test]
815+
fn test_order_skip_limit_wrapping() {
816+
// ORDER BY + SKIP + LIMIT should be Limit(Offset(Sort(Project(..))))
817+
let q = "MATCH (n:Person) RETURN n.name ORDER BY n.name SKIP 5 LIMIT 10";
818+
let ast = parse_cypher_query(q).unwrap();
819+
let mut planner = LogicalPlanner::new();
820+
let logical = planner.plan(&ast).unwrap();
821+
match logical {
822+
LogicalOperator::Limit { input, count } => {
823+
assert_eq!(count, 10);
824+
match *input {
825+
LogicalOperator::Offset {
826+
input: inner,
827+
offset,
828+
} => {
829+
assert_eq!(offset, 5);
830+
match *inner {
831+
LogicalOperator::Sort { input: inner2, .. } => match *inner2 {
832+
LogicalOperator::Project { .. } => {}
833+
_ => panic!("Expected Project under Sort"),
834+
},
835+
_ => panic!("Expected Sort under Offset"),
836+
}
837+
}
838+
_ => panic!("Expected Offset under Limit"),
839+
}
840+
}
841+
_ => panic!("Expected Limit at top level"),
842+
}
843+
}
844+
845+
#[test]
846+
fn test_skip_only_wrapping() {
847+
// SKIP only should be Offset(Project(..))
848+
let q = "MATCH (n:Person) RETURN n.name SKIP 3";
849+
let ast = parse_cypher_query(q).unwrap();
850+
let mut planner = LogicalPlanner::new();
851+
let logical = planner.plan(&ast).unwrap();
852+
match logical {
853+
LogicalOperator::Offset { input, offset } => {
854+
assert_eq!(offset, 3);
855+
match *input {
856+
LogicalOperator::Project { .. } => {}
857+
_ => panic!("Expected Project under Offset"),
858+
}
859+
}
860+
_ => panic!("Expected Offset at top level"),
861+
}
862+
}
863+
799864
#[test]
800865
fn test_relationship_properties_pushed_into_expand() {
801866
let q = "MATCH (a)-[:KNOWS {since: 2020}]->(b) RETURN b";

rust/lance-graph/src/parser.rs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ fn cypher_query(input: &str) -> IResult<&str, CypherQuery> {
4545
let (input, where_clause) = opt(where_clause)(input)?;
4646
let (input, return_clause) = return_clause(input)?;
4747
let (input, order_by) = opt(order_by_clause)(input)?;
48-
let (input, limit) = opt(limit_clause)(input)?;
48+
let (input, (skip, limit)) = pagination_clauses(input)?;
4949
let (input, _) = multispace0(input)?;
5050

5151
Ok((
@@ -56,6 +56,7 @@ fn cypher_query(input: &str) -> IResult<&str, CypherQuery> {
5656
return_clause,
5757
limit,
5858
order_by,
59+
skip,
5960
},
6061
))
6162
}
@@ -389,6 +390,49 @@ fn limit_clause(input: &str) -> IResult<&str, u64> {
389390
Ok((input, limit as u64))
390391
}
391392

393+
// Parse a SKIP clause
394+
fn skip_clause(input: &str) -> IResult<&str, u64> {
395+
let (input, _) = multispace0(input)?;
396+
let (input, _) = tag_no_case("SKIP")(input)?;
397+
let (input, _) = multispace1(input)?;
398+
let (input, skip) = integer_literal(input)?;
399+
400+
Ok((input, skip as u64))
401+
}
402+
403+
// Parse pagination clauses (SKIP and LIMIT)
404+
fn pagination_clauses(input: &str) -> IResult<&str, (Option<u64>, Option<u64>)> {
405+
let (mut remaining, _) = multispace0(input)?;
406+
let mut skip: Option<u64> = None;
407+
let mut limit: Option<u64> = None;
408+
409+
loop {
410+
let before = remaining;
411+
412+
if skip.is_none() {
413+
if let Ok((i, s)) = skip_clause(remaining) {
414+
skip = Some(s);
415+
remaining = i;
416+
continue;
417+
}
418+
}
419+
420+
if limit.is_none() {
421+
if let Ok((i, l)) = limit_clause(remaining) {
422+
limit = Some(l);
423+
remaining = i;
424+
continue;
425+
}
426+
}
427+
428+
if before == remaining {
429+
break;
430+
}
431+
}
432+
433+
Ok((remaining, (skip, limit)))
434+
}
435+
392436
// Helper parsers
393437

394438
// Parse an identifier
@@ -572,4 +616,41 @@ mod tests {
572616

573617
assert_eq!(result.limit, Some(10));
574618
}
619+
620+
#[test]
621+
fn test_parse_query_with_skip() {
622+
let query = "MATCH (n:Person) RETURN n.name SKIP 5";
623+
let result = parse_cypher_query(query).unwrap();
624+
625+
assert_eq!(result.skip, Some(5));
626+
assert_eq!(result.limit, None);
627+
}
628+
629+
#[test]
630+
fn test_parse_query_with_skip_and_limit() {
631+
let query = "MATCH (n:Person) RETURN n.name SKIP 5 LIMIT 10";
632+
let result = parse_cypher_query(query).unwrap();
633+
634+
assert_eq!(result.skip, Some(5));
635+
assert_eq!(result.limit, Some(10));
636+
}
637+
638+
#[test]
639+
fn test_parse_query_with_skip_and_order_by() {
640+
let query = "MATCH (n:Person) RETURN n.name ORDER BY n.age SKIP 5";
641+
let result = parse_cypher_query(query).unwrap();
642+
643+
assert_eq!(result.skip, Some(5));
644+
assert!(result.order_by.is_some());
645+
}
646+
647+
#[test]
648+
fn test_parse_query_with_skip_order_by_and_limit() {
649+
let query = "MATCH (n:Person) RETURN n.name ORDER BY n.age SKIP 5 LIMIT 10";
650+
let result = parse_cypher_query(query).unwrap();
651+
652+
assert_eq!(result.skip, Some(5));
653+
assert_eq!(result.limit, Some(10));
654+
assert!(result.order_by.is_some());
655+
}
575656
}

0 commit comments

Comments
 (0)