diff --git a/rust/lance-graph/src/ast.rs b/rust/lance-graph/src/ast.rs index e242ea5e..4c35070c 100644 --- a/rust/lance-graph/src/ast.rs +++ b/rust/lance-graph/src/ast.rs @@ -218,11 +218,16 @@ pub enum BooleanExpression { expression: ValueExpression, list: Vec, }, - /// LIKE pattern matching + /// LIKE pattern matching (case-sensitive) Like { expression: ValueExpression, pattern: String, }, + /// ILIKE pattern matching (case-insensitive) + ILike { + expression: ValueExpression, + pattern: String, + }, /// CONTAINS substring matching Contains { expression: ValueExpression, diff --git a/rust/lance-graph/src/datafusion_planner/expression.rs b/rust/lance-graph/src/datafusion_planner/expression.rs index 8d58e7d3..c4ef0c90 100644 --- a/rust/lance-graph/src/datafusion_planner/expression.rs +++ b/rust/lance-graph/src/datafusion_planner/expression.rs @@ -75,6 +75,10 @@ pub(crate) fn to_df_boolean_expr(expr: &BooleanExpression) -> Expr { expression, pattern, } => create_like_expr(expression, pattern, false), + BE::ILike { + expression, + pattern, + } => create_like_expr(expression, pattern, true), BE::Contains { expression, substring, @@ -476,6 +480,85 @@ mod tests { } } + #[test] + fn test_boolean_expr_ilike() { + let expr = BooleanExpression::ILike { + expression: ValueExpression::Property(PropertyRef { + variable: "p".into(), + property: "name".into(), + }), + pattern: "alice%".into(), + }; + + if let Expr::Like(like_expr) = to_df_boolean_expr(&expr) { + assert!(!like_expr.negated, "Should not be negated"); + assert!( + like_expr.case_insensitive, + "ILIKE should be case insensitive" + ); + assert_eq!(like_expr.escape_char, None, "Should have no escape char"); + match *like_expr.expr { + Expr::Column(ref col_expr) => { + assert_eq!(col_expr.name(), "p__name"); + } + other => panic!("Expected column expression, got {:?}", other), + } + // Check pattern is a literal + match *like_expr.pattern { + Expr::Literal(ref scalar, _) => { + let s = format!("{:?}", scalar); + assert!( + s.contains("alice%"), + "Pattern should be 'alice%', got: {}", + s + ); + } + other => panic!("Expected literal pattern, got {:?}", other), + } + } else { + panic!("Expected Like expression"); + } + } + + #[test] + fn test_boolean_expr_like_vs_ilike_case_sensitivity() { + // Test LIKE (case-sensitive) + let like_expr = BooleanExpression::Like { + expression: ValueExpression::Property(PropertyRef { + variable: "p".into(), + property: "name".into(), + }), + pattern: "Test%".into(), + }; + + if let Expr::Like(like) = to_df_boolean_expr(&like_expr) { + assert!( + !like.case_insensitive, + "LIKE should be case-sensitive (case_insensitive = false)" + ); + } else { + panic!("Expected Like expression"); + } + + // Test ILIKE (case-insensitive) + let ilike_expr = BooleanExpression::ILike { + expression: ValueExpression::Property(PropertyRef { + variable: "p".into(), + property: "name".into(), + }), + pattern: "Test%".into(), + }; + + if let Expr::Like(ilike) = to_df_boolean_expr(&ilike_expr) { + assert!( + ilike.case_insensitive, + "ILIKE should be case-insensitive (case_insensitive = true)" + ); + } else { + panic!("Expected Like expression"); + } + } + #[test] fn test_boolean_expr_like_with_wildcard() { let expr = BooleanExpression::Like { diff --git a/rust/lance-graph/src/parser.rs b/rust/lance-graph/src/parser.rs index d3044b86..6f0a462d 100644 --- a/rust/lance-graph/src/parser.rs +++ b/rust/lance-graph/src/parser.rs @@ -341,6 +341,18 @@ fn comparison_expression(input: &str) -> IResult<&str, BooleanExpression> { }, )); } + // Match ILIKE pattern (case-insensitive LIKE) + if let Ok((input_after_ilike, (_, _, pattern))) = + tuple((tag_no_case("ILIKE"), multispace0, string_literal))(input) + { + return Ok(( + input_after_ilike, + BooleanExpression::ILike { + expression: left, + pattern, + }, + )); + } // Match CONTAINS substring if let Ok((input_after_contains, (_, _, substring))) = tuple((tag_no_case("CONTAINS"), multispace0, string_literal))(input) @@ -1265,4 +1277,62 @@ mod tests { _ => panic!("Expected AND expression"), } } + + #[test] + fn test_parse_ilike_pattern() { + let query = "MATCH (n:Person) WHERE n.name ILIKE 'alice%' RETURN n.name"; + let result = parse_cypher_query(query); + assert!(result.is_ok(), "ILIKE pattern should parse successfully"); + + let ast = result.unwrap(); + let where_clause = ast.where_clause.expect("Expected WHERE clause"); + + match where_clause.expression { + BooleanExpression::ILike { + expression, + pattern, + } => { + match expression { + ValueExpression::Property(prop) => { + assert_eq!(prop.variable, "n"); + assert_eq!(prop.property, "name"); + } + _ => panic!("Expected property expression"), + } + assert_eq!(pattern, "alice%"); + } + _ => panic!("Expected ILIKE expression"), + } + } + + #[test] + fn test_parse_like_and_ilike_together() { + let query = + "MATCH (n:Person) WHERE n.name LIKE 'Alice%' OR n.name ILIKE 'bob%' RETURN n.name"; + let result = parse_cypher_query(query); + assert!(result.is_ok(), "LIKE and ILIKE together should parse"); + + let ast = result.unwrap(); + let where_clause = ast.where_clause.expect("Expected WHERE clause"); + + match where_clause.expression { + BooleanExpression::Or(left, right) => { + // Left should be LIKE (case-sensitive) + match *left { + BooleanExpression::Like { pattern, .. } => { + assert_eq!(pattern, "Alice%"); + } + _ => panic!("Expected LIKE expression on left"), + } + // Right should be ILIKE (case-insensitive) + match *right { + BooleanExpression::ILike { pattern, .. } => { + assert_eq!(pattern, "bob%"); + } + _ => panic!("Expected ILIKE expression on right"), + } + } + _ => panic!("Expected OR expression"), + } + } } diff --git a/rust/lance-graph/src/semantic.rs b/rust/lance-graph/src/semantic.rs index bbf1731f..9e3cbd7b 100644 --- a/rust/lance-graph/src/semantic.rs +++ b/rust/lance-graph/src/semantic.rs @@ -247,6 +247,9 @@ impl SemanticAnalyzer { BooleanExpression::Like { expression, .. } => { self.analyze_value_expression(expression)?; } + BooleanExpression::ILike { expression, .. } => { + self.analyze_value_expression(expression)?; + } BooleanExpression::Contains { expression, .. } => { self.analyze_value_expression(expression)?; }