From fa119c8c0bf3ed58348de9e5b85faa97c6de652b Mon Sep 17 00:00:00 2001 From: jackylee-ch Date: Sat, 31 Oct 2026 19:45:25 +0800 Subject: [PATCH] fix(duckdb): escape LIKE metacharacters in pushed string filters DuckDB matches the argument of contains, prefix and suffix literally, and its LIKE has no escape character. The converter passed both to Vortex LIKE unchanged, where % and _ are wildcards and \ escapes the next character, so pushed filters returned wrong rows: prefix(s, 'page_') and s GLOB 'page_*' also matched pageXview, and s LIKE 'C:\%' matched C:% instead of C:\x. Escape %, _ and \ in those arguments and \ in a constant LIKE pattern, and leave a LIKE whose pattern is not a constant to DuckDB. Signed-off-by: jackylee-ch --- vortex-duckdb/src/convert/expr.rs | 22 ++++-- .../slt/duckdb/like_pushdown.slt | 71 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 vortex-sqllogictest/slt/duckdb/like_pushdown.slt diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index 4ed3cc66060..b487ab4fdc4 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -106,6 +106,18 @@ fn string_matcher_pattern(children: &[&ExpressionRef]) -> Option { } } +/// Escape each of `chars` in `pattern` with `\`, the escape character of Vortex LIKE. +fn escape_like(pattern: &str, chars: &[char]) -> String { + let mut escaped = String::with_capacity(pattern.len()); + for c in pattern.chars() { + if chars.contains(&c) { + escaped.push('\\'); + } + escaped.push(c); + } + escaped +} + /// Whether the expression's return type is a `LIST` or fixed-size `ARRAY`. fn returns_a_list(expr: &ExpressionRef) -> bool { matches!( @@ -312,17 +324,17 @@ fn try_from_bound_function( like @ ("~~" | "!~~") => { let children: Vec<_> = func.children().collect(); vortex_ensure_eq!(children.len(), 2); - let Some(string) = try_from_expression_inner(children[0], ctx)? else { + let Some(pattern) = from_bound_str(children[1]) else { return Ok(None); }; - let Some(target) = try_from_expression_inner(children[1], ctx)? else { + let Some(string) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; let opts = LikeOptions { negated: like == "!~~", case_insensitive: false, }; - Like.new_expr(opts, [string, target]) + Like.new_expr(opts, [string, lit(escape_like(&pattern, &['\\']))]) } matchers @ ("contains" | "prefix" | "suffix") => { let children: Vec<_> = func.children().collect(); @@ -332,6 +344,7 @@ fn try_from_bound_function( let Some(value) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; + let pattern = escape_like(&pattern, &['%', '_', '\\']); let pattern = match matchers { "contains" => format!("%{pattern}%"), "prefix" => format!("{pattern}%"), @@ -459,7 +472,8 @@ pub fn can_push_expression(value: &ExpressionRef) -> bool { ExpressionClass::BoundFunction(func) => { let children: Vec<_> = func.children().collect(); let supported = match func.scalar_function.name() { - "~~" | "!~~" | "strlen" => true, + "~~" | "!~~" => children.len() == 2 && from_bound_str(children[1]).is_some(), + "strlen" => true, "struct_extract" => children.len() == 2 && from_bound_str(children[1]).is_some(), "contains" | "prefix" | "suffix" => string_matcher_pattern(&children).is_some(), "array_length" => children.len() == 1, diff --git a/vortex-sqllogictest/slt/duckdb/like_pushdown.slt b/vortex-sqllogictest/slt/duckdb/like_pushdown.slt new file mode 100644 index 00000000000..3b37d90a99d --- /dev/null +++ b/vortex-sqllogictest/slt/duckdb/like_pushdown.slt @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +include ../setup.slt.no + +statement ok +COPY (SELECT * FROM (VALUES ('a_c', 'a_c'), ('abc', 'b'), ('100%', '1%'), ('1000', '1%'), + ('page_view', 'p%'), ('pageXview', 'p%'), ('C:' || chr(92) || 'x', 'C:' || chr(92) || '_'), ('C:%', 'C:%'), + (NULL, NULL)) t(s, p)) +TO '${WORK_DIR}/like-pushdown.vortex'; + +# contains, prefix and suffix match their argument literally +query T +SELECT s FROM '${WORK_DIR}/like-pushdown.vortex' WHERE contains(s, 'a_c'); +---- +a_c + +query T +SELECT s FROM '${WORK_DIR}/like-pushdown.vortex' WHERE prefix(s, 'page_'); +---- +page_view + +query T +SELECT s FROM '${WORK_DIR}/like-pushdown.vortex' WHERE s GLOB 'page_*'; +---- +page_view + +query T +SELECT s FROM '${WORK_DIR}/like-pushdown.vortex' WHERE suffix(s, '0%'); +---- +100% + +query TT +EXPLAIN (FORMAT json) SELECT s FROM '${WORK_DIR}/like-pushdown.vortex' WHERE prefix(s, 'page_'); +---- +:"Filters": "[^"]* like + +# A backslash in a LIKE pattern matches itself +query T +SELECT replace(s, chr(92), '/') FROM '${WORK_DIR}/like-pushdown.vortex' WHERE s LIKE 'C:' || chr(92) || '%'; +---- +C:/x + +query T +SELECT replace(s, chr(92), '/') FROM '${WORK_DIR}/like-pushdown.vortex' WHERE s LIKE 'C:' || chr(92) || '_'; +---- +C:/x + +# A pattern that isn't a constant is evaluated by DuckDB +query T +SELECT replace(s, chr(92), '/') FROM '${WORK_DIR}/like-pushdown.vortex' WHERE s LIKE p ORDER BY s; +---- +100% +1000 +C:% +C:/x +a_c +pageXview +page_view + +query T +SELECT replace(s, chr(92), '/') FROM '${WORK_DIR}/like-pushdown.vortex' WHERE s LIKE s ORDER BY s; +---- +100% +1000 +C:% +C:/x +a_c +abc +pageXview +page_view