Skip to content

Commit 2d5f0ef

Browse files
author
jianjian.xie
committed
add ilike support
1 parent 3adf8e9 commit 2d5f0ef

File tree

4 files changed

+241
-1
lines changed

4 files changed

+241
-1
lines changed

rust/lance-graph/src/ast.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,16 @@ pub enum BooleanExpression {
218218
expression: ValueExpression,
219219
list: Vec<ValueExpression>,
220220
},
221-
/// LIKE pattern matching
221+
/// LIKE pattern matching (case-sensitive)
222222
Like {
223223
expression: ValueExpression,
224224
pattern: String,
225225
},
226+
/// ILIKE pattern matching (case-insensitive)
227+
ILike {
228+
expression: ValueExpression,
229+
pattern: String,
230+
},
226231
/// CONTAINS substring matching
227232
Contains {
228233
expression: ValueExpression,

rust/lance-graph/src/datafusion_planner/expression.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ pub(crate) fn to_df_boolean_expr(expr: &BooleanExpression) -> Expr {
7575
expression,
7676
pattern,
7777
} => create_like_expr(expression, pattern, false),
78+
BE::ILike {
79+
expression,
80+
pattern,
81+
} => create_like_expr(expression, pattern, true),
7882
BE::Contains {
7983
expression,
8084
substring,
@@ -476,6 +480,104 @@ mod tests {
476480
}
477481
}
478482

483+
#[test]
484+
fn test_boolean_expr_ilike() {
485+
let expr = BooleanExpression::ILike {
486+
expression: ValueExpression::Property(PropertyRef {
487+
variable: "p".into(),
488+
property: "name".into(),
489+
}),
490+
pattern: "alice%".into(),
491+
};
492+
493+
if let Expr::Like(like_expr) = to_df_boolean_expr(&expr) {
494+
assert!(!like_expr.negated, "Should not be negated");
495+
assert!(
496+
like_expr.case_insensitive,
497+
"ILIKE should be case insensitive"
498+
);
499+
assert_eq!(like_expr.escape_char, None, "Should have no escape char");
500+
match *like_expr.expr {
501+
Expr::Column(ref col_expr) => {
502+
assert_eq!(col_expr.name(), "p__name");
503+
}
504+
other => panic!("Expected column expression, got {:?}", other),
505+
}
506+
// Check pattern is a literal
507+
match *like_expr.pattern {
508+
Expr::Literal(ref scalar, _) => {
509+
let s = format!("{:?}", scalar);
510+
assert!(
511+
s.contains("alice%"),
512+
"Pattern should be 'alice%', got: {}",
513+
s
514+
);
515+
}
516+
other => panic!("Expected literal pattern, got {:?}", other),
517+
}
518+
} else {
519+
panic!("Expected Like expression");
520+
}
521+
}
522+
523+
#[test]
524+
fn test_boolean_expr_ilike_with_wildcard() {
525+
let expr = BooleanExpression::ILike {
526+
expression: ValueExpression::Property(PropertyRef {
527+
variable: "p".into(),
528+
property: "email".into(),
529+
}),
530+
pattern: "%@EXAMPLE.COM".into(),
531+
};
532+
533+
let df_expr = to_df_boolean_expr(&expr);
534+
let s = format!("{:?}", df_expr);
535+
assert!(
536+
s.contains("Like") || s.contains("like"),
537+
"Should be a LIKE expression"
538+
);
539+
assert!(s.contains("p__email"), "Should contain column reference");
540+
}
541+
542+
#[test]
543+
fn test_boolean_expr_like_vs_ilike_case_sensitivity() {
544+
// Test LIKE (case-sensitive)
545+
let like_expr = BooleanExpression::Like {
546+
expression: ValueExpression::Property(PropertyRef {
547+
variable: "p".into(),
548+
property: "name".into(),
549+
}),
550+
pattern: "Test%".into(),
551+
};
552+
553+
if let Expr::Like(like) = to_df_boolean_expr(&like_expr) {
554+
assert!(
555+
!like.case_insensitive,
556+
"LIKE should be case-sensitive (case_insensitive = false)"
557+
);
558+
} else {
559+
panic!("Expected Like expression");
560+
}
561+
562+
// Test ILIKE (case-insensitive)
563+
let ilike_expr = BooleanExpression::ILike {
564+
expression: ValueExpression::Property(PropertyRef {
565+
variable: "p".into(),
566+
property: "name".into(),
567+
}),
568+
pattern: "Test%".into(),
569+
};
570+
571+
if let Expr::Like(ilike) = to_df_boolean_expr(&ilike_expr) {
572+
assert!(
573+
ilike.case_insensitive,
574+
"ILIKE should be case-insensitive (case_insensitive = true)"
575+
);
576+
} else {
577+
panic!("Expected Like expression");
578+
}
579+
}
580+
479581
#[test]
480582
fn test_boolean_expr_like_with_wildcard() {
481583
let expr = BooleanExpression::Like {

rust/lance-graph/src/parser.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,18 @@ fn comparison_expression(input: &str) -> IResult<&str, BooleanExpression> {
341341
},
342342
));
343343
}
344+
// Match ILIKE pattern (case-insensitive LIKE)
345+
if let Ok((input_after_ilike, (_, _, pattern))) =
346+
tuple((tag_no_case("ILIKE"), multispace0, string_literal))(input)
347+
{
348+
return Ok((
349+
input_after_ilike,
350+
BooleanExpression::ILike {
351+
expression: left,
352+
pattern,
353+
},
354+
));
355+
}
344356
// Match CONTAINS substring
345357
if let Ok((input_after_contains, (_, _, substring))) =
346358
tuple((tag_no_case("CONTAINS"), multispace0, string_literal))(input)
@@ -1265,4 +1277,122 @@ mod tests {
12651277
_ => panic!("Expected AND expression"),
12661278
}
12671279
}
1280+
1281+
#[test]
1282+
fn test_parse_ilike_pattern() {
1283+
let query = "MATCH (n:Person) WHERE n.name ILIKE 'alice%' RETURN n.name";
1284+
let result = parse_cypher_query(query);
1285+
assert!(result.is_ok(), "ILIKE pattern should parse successfully");
1286+
1287+
let ast = result.unwrap();
1288+
let where_clause = ast.where_clause.expect("Expected WHERE clause");
1289+
1290+
match where_clause.expression {
1291+
BooleanExpression::ILike {
1292+
expression,
1293+
pattern,
1294+
} => {
1295+
match expression {
1296+
ValueExpression::Property(prop) => {
1297+
assert_eq!(prop.variable, "n");
1298+
assert_eq!(prop.property, "name");
1299+
}
1300+
_ => panic!("Expected property expression"),
1301+
}
1302+
assert_eq!(pattern, "alice%");
1303+
}
1304+
_ => panic!("Expected ILIKE expression"),
1305+
}
1306+
}
1307+
1308+
#[test]
1309+
fn test_parse_ilike_case_insensitive_keyword() {
1310+
// Test that the ILIKE keyword itself is case-insensitive
1311+
let query = "MATCH (n:Person) WHERE n.name ilike 'ALICE%' RETURN n.name";
1312+
let result = parse_cypher_query(query);
1313+
assert!(result.is_ok(), "ilike (lowercase) should parse");
1314+
1315+
match &result.unwrap().where_clause.unwrap().expression {
1316+
BooleanExpression::ILike { pattern, .. } => {
1317+
assert_eq!(pattern, "ALICE%");
1318+
}
1319+
_ => panic!("Expected ILIKE expression"),
1320+
}
1321+
}
1322+
1323+
#[test]
1324+
fn test_parse_ilike_with_double_quotes() {
1325+
let query = r#"MATCH (n:Person) WHERE n.email ILIKE "%@EXAMPLE.COM" RETURN n.email"#;
1326+
let result = parse_cypher_query(query);
1327+
assert!(result.is_ok(), "ILIKE with double quotes should parse");
1328+
1329+
let ast = result.unwrap();
1330+
let where_clause = ast.where_clause.expect("Expected WHERE clause");
1331+
1332+
match where_clause.expression {
1333+
BooleanExpression::ILike { pattern, .. } => {
1334+
assert_eq!(pattern, "%@EXAMPLE.COM");
1335+
}
1336+
_ => panic!("Expected ILIKE expression"),
1337+
}
1338+
}
1339+
1340+
#[test]
1341+
fn test_parse_ilike_in_complex_where() {
1342+
let query = "MATCH (n:Person) WHERE n.age > 20 AND n.name ILIKE 'j%' RETURN n.name";
1343+
let result = parse_cypher_query(query);
1344+
assert!(result.is_ok(), "ILIKE in complex WHERE should parse");
1345+
1346+
let ast = result.unwrap();
1347+
let where_clause = ast.where_clause.expect("Expected WHERE clause");
1348+
1349+
match where_clause.expression {
1350+
BooleanExpression::And(left, right) => {
1351+
// Left should be age > 20
1352+
match *left {
1353+
BooleanExpression::Comparison { .. } => {}
1354+
_ => panic!("Expected comparison on left"),
1355+
}
1356+
// Right should be ILIKE
1357+
match *right {
1358+
BooleanExpression::ILike { pattern, .. } => {
1359+
assert_eq!(pattern, "j%");
1360+
}
1361+
_ => panic!("Expected ILIKE expression on right"),
1362+
}
1363+
}
1364+
_ => panic!("Expected AND expression"),
1365+
}
1366+
}
1367+
1368+
#[test]
1369+
fn test_parse_like_and_ilike_together() {
1370+
let query =
1371+
"MATCH (n:Person) WHERE n.name LIKE 'Alice%' OR n.name ILIKE 'bob%' RETURN n.name";
1372+
let result = parse_cypher_query(query);
1373+
assert!(result.is_ok(), "LIKE and ILIKE together should parse");
1374+
1375+
let ast = result.unwrap();
1376+
let where_clause = ast.where_clause.expect("Expected WHERE clause");
1377+
1378+
match where_clause.expression {
1379+
BooleanExpression::Or(left, right) => {
1380+
// Left should be LIKE (case-sensitive)
1381+
match *left {
1382+
BooleanExpression::Like { pattern, .. } => {
1383+
assert_eq!(pattern, "Alice%");
1384+
}
1385+
_ => panic!("Expected LIKE expression on left"),
1386+
}
1387+
// Right should be ILIKE (case-insensitive)
1388+
match *right {
1389+
BooleanExpression::ILike { pattern, .. } => {
1390+
assert_eq!(pattern, "bob%");
1391+
}
1392+
_ => panic!("Expected ILIKE expression on right"),
1393+
}
1394+
}
1395+
_ => panic!("Expected OR expression"),
1396+
}
1397+
}
12681398
}

rust/lance-graph/src/semantic.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ impl SemanticAnalyzer {
247247
BooleanExpression::Like { expression, .. } => {
248248
self.analyze_value_expression(expression)?;
249249
}
250+
BooleanExpression::ILike { expression, .. } => {
251+
self.analyze_value_expression(expression)?;
252+
}
250253
BooleanExpression::Contains { expression, .. } => {
251254
self.analyze_value_expression(expression)?;
252255
}

0 commit comments

Comments
 (0)