Skip to content

Commit 6c523e2

Browse files
Allen ChengAllen Cheng
authored andcommitted
feat(graph): add WITH clause with query chaining support
Adds WITH clause for intermediate projections, aggregations, and query chaining in Cypher queries. Supported syntax: - WITH projection: WITH p.name AS name - WITH aggregation: WITH city, count(*) AS total - WITH ORDER BY/LIMIT: WITH p ORDER BY p.age DESC LIMIT 10 - Post-WITH WHERE: WITH ... WHERE total > 1 - Post-WITH MATCH: WITH ... MATCH (p2:Person) ... Changes: - Add WithClause to AST with items, order_by, limit fields - Add with_clause, post_with_match_clauses, post_with_where_clause to CypherQuery - Parse WITH clause and post-WITH MATCH/WHERE in parser - Add semantic analysis for WITH scope - Add plan_with_clause in logical planner - Chain post-WITH MATCH using plan_match_clause_with_base - Add 5 comprehensive tests for WITH functionality Note: WITH p (passing whole node) then MATCH (p)-[]->(f) requires explicit property projection. Use WITH p.id AS id ... instead.
1 parent 1923f42 commit 6c523e2

6 files changed

Lines changed: 418 additions & 9 deletions

File tree

rust/lance-graph/src/ast.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ use std::collections::HashMap;
1515
pub struct CypherQuery {
1616
/// MATCH clauses
1717
pub match_clauses: Vec<MatchClause>,
18-
/// WHERE clause (optional)
18+
/// WHERE clause (optional, before WITH if present)
1919
pub where_clause: Option<WhereClause>,
20+
/// WITH clause (optional) - intermediate projection/aggregation
21+
pub with_clause: Option<WithClause>,
22+
/// MATCH clauses after WITH (optional) - query chaining
23+
pub post_with_match_clauses: Vec<MatchClause>,
24+
/// WHERE clause after WITH (optional) - filters the WITH results
25+
pub post_with_where_clause: Option<WhereClause>,
2026
/// RETURN clause
2127
pub return_clause: ReturnClause,
2228
/// LIMIT clause (optional)
@@ -323,6 +329,20 @@ pub enum ArithmeticOperator {
323329
Modulo,
324330
}
325331

332+
/// WITH clause for intermediate projections/aggregations
333+
///
334+
/// WITH acts as a query stage boundary, projecting results that become
335+
/// the input for subsequent clauses.
336+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337+
pub struct WithClause {
338+
/// Items to project (similar to RETURN)
339+
pub items: Vec<ReturnItem>,
340+
/// Optional ORDER BY within WITH
341+
pub order_by: Option<OrderByClause>,
342+
/// Optional LIMIT within WITH
343+
pub limit: Option<u64>,
344+
}
345+
326346
/// RETURN clause specifying what to return
327347
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328348
pub struct ReturnClause {

rust/lance-graph/src/logical_plan.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,32 @@ impl LogicalPlanner {
156156
// Start with the MATCH clause(s)
157157
let mut plan = self.plan_match_clauses(&query.match_clauses)?;
158158

159-
// Apply WHERE clause if present
159+
// Apply WHERE clause if present (before WITH)
160160
if let Some(where_clause) = &query.where_clause {
161161
plan = LogicalOperator::Filter {
162162
input: Box::new(plan),
163163
predicate: where_clause.expression.clone(),
164164
};
165165
}
166166

167+
// Apply WITH clause if present (intermediate projection/aggregation)
168+
if let Some(with_clause) = &query.with_clause {
169+
plan = self.plan_with_clause(with_clause, plan)?;
170+
}
171+
172+
// Apply post-WITH MATCH clauses if present (query chaining)
173+
for match_clause in &query.post_with_match_clauses {
174+
plan = self.plan_match_clause_with_base(Some(plan), match_clause)?;
175+
}
176+
177+
// Apply post-WITH WHERE clause if present
178+
if let Some(post_where) = &query.post_with_where_clause {
179+
plan = LogicalOperator::Filter {
180+
input: Box::new(plan),
181+
predicate: post_where.expression.clone(),
182+
};
183+
}
184+
167185
// Apply RETURN clause
168186
plan = self.plan_return_clause(&query.return_clause, plan)?;
169187

@@ -429,6 +447,53 @@ impl LogicalPlanner {
429447

430448
Ok(plan)
431449
}
450+
451+
/// Plan WITH clause - intermediate projection/aggregation with optional ORDER BY and LIMIT
452+
fn plan_with_clause(
453+
&self,
454+
with_clause: &WithClause,
455+
input: LogicalOperator,
456+
) -> Result<LogicalOperator> {
457+
// WITH creates a projection (like RETURN)
458+
let projections = with_clause
459+
.items
460+
.iter()
461+
.map(|item| ProjectionItem {
462+
expression: item.expression.clone(),
463+
alias: item.alias.clone(),
464+
})
465+
.collect();
466+
467+
let mut plan = LogicalOperator::Project {
468+
input: Box::new(input),
469+
projections,
470+
};
471+
472+
// Apply ORDER BY within WITH if present
473+
if let Some(order_by) = &with_clause.order_by {
474+
plan = LogicalOperator::Sort {
475+
input: Box::new(plan),
476+
sort_items: order_by
477+
.items
478+
.iter()
479+
.map(|item| SortItem {
480+
expression: item.expression.clone(),
481+
direction: item.direction.clone(),
482+
})
483+
.collect(),
484+
};
485+
}
486+
487+
// Apply LIMIT within WITH if present
488+
if let Some(limit) = with_clause.limit {
489+
plan = LogicalOperator::Limit {
490+
input: Box::new(plan),
491+
count: limit,
492+
};
493+
}
494+
495+
Ok(plan)
496+
}
432497
}
433498

434499
impl Default for LogicalPlanner {

rust/lance-graph/src/parser.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,20 @@ pub fn parse_cypher_query(input: &str) -> Result<CypherQuery> {
4242
fn cypher_query(input: &str) -> IResult<&str, CypherQuery> {
4343
let (input, _) = multispace0(input)?;
4444
let (input, match_clauses) = many0(match_clause)(input)?;
45-
let (input, where_clause) = opt(where_clause)(input)?;
45+
let (input, pre_with_where) = opt(where_clause)(input)?;
46+
47+
// Optional WITH clause with optional post-WITH MATCH and WHERE
48+
let (input, with_result) = opt(with_clause)(input)?;
49+
// Only try to parse post-WITH clauses if we have a WITH clause
50+
let (input, post_with_matches, post_with_where) = match with_result {
51+
Some(_) => {
52+
let (input, matches) = many0(match_clause)(input)?;
53+
let (input, where_cl) = opt(where_clause)(input)?;
54+
(input, matches, where_cl)
55+
}
56+
None => (input, vec![], None),
57+
};
58+
4659
let (input, return_clause) = return_clause(input)?;
4760
let (input, order_by) = opt(order_by_clause)(input)?;
4861
let (input, (skip, limit)) = pagination_clauses(input)?;
@@ -52,7 +65,10 @@ fn cypher_query(input: &str) -> IResult<&str, CypherQuery> {
5265
input,
5366
CypherQuery {
5467
match_clauses,
55-
where_clause,
68+
where_clause: pre_with_where,
69+
with_clause: with_result,
70+
post_with_match_clauses: post_with_matches,
71+
post_with_where_clause: post_with_where,
5672
return_clause,
5773
limit,
5874
order_by,
@@ -657,6 +673,26 @@ fn property_reference(input: &str) -> IResult<&str, PropertyRef> {
657673
))
658674
}
659675

676+
677+
// Parse a WITH clause (intermediate projection/aggregation)
678+
fn with_clause(input: &str) -> IResult<&str, WithClause> {
679+
let (input, _) = multispace0(input)?;
680+
let (input, _) = tag_no_case("WITH")(input)?;
681+
let (input, _) = multispace1(input)?;
682+
let (input, items) = separated_list0(comma_ws, return_item)(input)?;
683+
let (input, order_by) = opt(order_by_clause)(input)?;
684+
let (input, limit) = opt(limit_clause)(input)?;
685+
686+
Ok((
687+
input,
688+
WithClause {
689+
items,
690+
order_by,
691+
limit,
692+
},
693+
))
694+
}
695+
660696
// Parse a RETURN clause
661697
fn return_clause(input: &str) -> IResult<&str, ReturnClause> {
662698
let (input, _) = multispace0(input)?;

rust/lance-graph/src/query.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,9 @@ impl CypherQueryBuilder {
11781178
where_clause: self
11791179
.where_expression
11801180
.map(|expr| crate::ast::WhereClause { expression: expr }),
1181+
with_clause: None, // WITH not supported via builder yet
1182+
post_with_match_clauses: vec![],
1183+
post_with_where_clause: None,
11811184
return_clause: crate::ast::ReturnClause {
11821185
distinct: self.distinct,
11831186
items: self.return_items,

rust/lance-graph/src/semantic.rs

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ pub enum VariableType {
4444
pub enum ScopeType {
4545
Match,
4646
Where,
47+
With,
48+
PostWithWhere,
4749
Return,
4850
OrderBy,
4951
}
@@ -79,32 +81,48 @@ impl SemanticAnalyzer {
7981
}
8082
}
8183

82-
// Phase 2: Validate WHERE clause
84+
// Phase 2: Validate WHERE clause (before WITH)
8385
if let Some(where_clause) = &query.where_clause {
8486
self.current_scope = ScopeType::Where;
8587
if let Err(e) = self.analyze_where_clause(where_clause) {
8688
errors.push(format!("WHERE clause error: {}", e));
8789
}
8890
}
8991

90-
// Phase 3: Validate RETURN clause
92+
// Phase 3: Validate WITH clause if present
93+
if let Some(with_clause) = &query.with_clause {
94+
self.current_scope = ScopeType::With;
95+
if let Err(e) = self.analyze_with_clause(with_clause) {
96+
errors.push(format!("WITH clause error: {}", e));
97+
}
98+
}
99+
100+
// Phase 4: Validate post-WITH WHERE clause if present
101+
if let Some(post_where) = &query.post_with_where_clause {
102+
self.current_scope = ScopeType::PostWithWhere;
103+
if let Err(e) = self.analyze_where_clause(post_where) {
104+
errors.push(format!("Post-WITH WHERE clause error: {}", e));
105+
}
106+
}
107+
108+
// Phase 5: Validate RETURN clause
91109
self.current_scope = ScopeType::Return;
92110
if let Err(e) = self.analyze_return_clause(&query.return_clause) {
93111
errors.push(format!("RETURN clause error: {}", e));
94112
}
95113

96-
// Phase 4: Validate ORDER BY clause
114+
// Phase 6: Validate ORDER BY clause
97115
if let Some(order_by) = &query.order_by {
98116
self.current_scope = ScopeType::OrderBy;
99117
if let Err(e) = self.analyze_order_by_clause(order_by) {
100118
errors.push(format!("ORDER BY clause error: {}", e));
101119
}
102120
}
103121

104-
// Phase 5: Schema validation
122+
// Phase 7: Schema validation
105123
self.validate_schema(&mut warnings);
106124

107-
// Phase 6: Type checking
125+
// Phase 8: Type checking
108126
self.validate_types(&mut errors);
109127

110128
Ok(SemanticResult {
@@ -416,6 +434,21 @@ impl SemanticAnalyzer {
416434
Ok(())
417435
}
418436

437+
/// Analyze WITH clause
438+
fn analyze_with_clause(&mut self, with_clause: &WithClause) -> Result<()> {
439+
// Validate WITH item expressions (similar to RETURN)
440+
for item in &with_clause.items {
441+
self.analyze_value_expression(&item.expression)?;
442+
}
443+
// Validate ORDER BY within WITH if present
444+
if let Some(order_by) = &with_clause.order_by {
445+
for item in &order_by.items {
446+
self.analyze_value_expression(&item.expression)?;
447+
}
448+
}
449+
Ok(())
450+
}
451+
419452
/// Analyze ORDER BY clause
420453
fn analyze_order_by_clause(&mut self, order_by: &OrderByClause) -> Result<()> {
421454
for item in &order_by.items {
@@ -558,6 +591,9 @@ mod tests {
558591
let query = CypherQuery {
559592
match_clauses: vec![],
560593
where_clause: None,
594+
with_clause: None,
595+
post_with_match_clauses: vec![],
596+
post_with_where_clause: None,
561597
return_clause: ReturnClause {
562598
distinct: false,
563599
items: vec![ReturnItem {
@@ -585,6 +621,9 @@ mod tests {
585621
patterns: vec![GraphPattern::Node(node)],
586622
}],
587623
where_clause: None,
624+
with_clause: None,
625+
post_with_match_clauses: vec![],
626+
post_with_where_clause: None,
588627
return_clause: ReturnClause {
589628
distinct: false,
590629
items: vec![ReturnItem {
@@ -615,6 +654,9 @@ mod tests {
615654
patterns: vec![GraphPattern::Node(node1), GraphPattern::Node(node2)],
616655
}],
617656
where_clause: None,
657+
with_clause: None,
658+
post_with_match_clauses: vec![],
659+
post_with_where_clause: None,
618660
return_clause: ReturnClause {
619661
distinct: false,
620662
items: vec![],
@@ -661,6 +703,9 @@ mod tests {
661703
patterns: vec![GraphPattern::Path(path)],
662704
}],
663705
where_clause: None,
706+
with_clause: None,
707+
post_with_match_clauses: vec![],
708+
post_with_where_clause: None,
664709
return_clause: ReturnClause {
665710
distinct: false,
666711
items: vec![],
@@ -690,6 +735,9 @@ mod tests {
690735
patterns: vec![GraphPattern::Node(node)],
691736
}],
692737
where_clause: Some(where_clause),
738+
with_clause: None,
739+
post_with_match_clauses: vec![],
740+
post_with_where_clause: None,
693741
return_clause: ReturnClause {
694742
distinct: false,
695743
items: vec![],
@@ -729,6 +777,9 @@ mod tests {
729777
patterns: vec![GraphPattern::Path(path)],
730778
}],
731779
where_clause: None,
780+
with_clause: None,
781+
post_with_match_clauses: vec![],
782+
post_with_where_clause: None,
732783
return_clause: ReturnClause {
733784
distinct: false,
734785
items: vec![],
@@ -755,6 +806,9 @@ mod tests {
755806
patterns: vec![GraphPattern::Node(node)],
756807
}],
757808
where_clause: None,
809+
with_clause: None,
810+
post_with_match_clauses: vec![],
811+
post_with_where_clause: None,
758812
return_clause: ReturnClause {
759813
distinct: false,
760814
items: vec![],
@@ -792,6 +846,9 @@ mod tests {
792846
patterns: vec![GraphPattern::Node(node)],
793847
}],
794848
where_clause: None,
849+
with_clause: None,
850+
post_with_match_clauses: vec![],
851+
post_with_where_clause: None,
795852
return_clause: ReturnClause {
796853
distinct: false,
797854
items: vec![],
@@ -834,6 +891,9 @@ mod tests {
834891
patterns: vec![GraphPattern::Path(path)],
835892
}],
836893
where_clause: None,
894+
with_clause: None,
895+
post_with_match_clauses: vec![],
896+
post_with_where_clause: None,
837897
return_clause: ReturnClause {
838898
distinct: false,
839899
items: vec![],
@@ -898,6 +958,9 @@ mod tests {
898958
patterns: vec![GraphPattern::Path(path)],
899959
}],
900960
where_clause: None,
961+
with_clause: None,
962+
post_with_match_clauses: vec![],
963+
post_with_where_clause: None,
901964
return_clause: ReturnClause {
902965
distinct: false,
903966
items: vec![],

0 commit comments

Comments
 (0)