diff --git a/src/query/sql/src/planner/semantic/type_check.rs b/src/query/sql/src/planner/semantic/type_check.rs index 5553a7be5f5f0..1a00d012544a5 100644 --- a/src/query/sql/src/planner/semantic/type_check.rs +++ b/src/query/sql/src/planner/semantic/type_check.rs @@ -5620,13 +5620,22 @@ impl<'a> TypeChecker<'a> { like_str: &str, escape: &Option, ) -> Result> { - let new_like_str = if let Some(escape) = escape { - Cow::Owned(convert_escape_pattern( - like_str, - escape.chars().next().unwrap(), - )) - } else { - Cow::Borrowed(like_str) + let new_like_str = match escape.as_ref() { + Some(escape_literal) => { + let mut chars = escape_literal.chars(); + let Some(escape_char) = chars.next() else { + // Empty escape literals must stay on the builtin path to match runtime behavior. + return self.resolve_like_escape(op, span, left, right, escape); + }; + + if chars.next().is_some() { + // Preserve existing builtin behavior for non-single-character escape literals. + return self.resolve_like_escape(op, span, left, right, escape); + } + + Cow::Owned(convert_escape_pattern(like_str, escape_char)) + } + None => Cow::Borrowed(like_str), }; if check_percent(&new_like_str) { // Convert to `a is not null` diff --git a/src/query/sql/tests/it/planner.rs b/src/query/sql/tests/it/planner.rs index 1df4cf2012d30..67a080b4e51a6 100644 --- a/src/query/sql/tests/it/planner.rs +++ b/src/query/sql/tests/it/planner.rs @@ -143,6 +143,24 @@ async fn test_lite_replay_service_optimizer_cases() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_like_escape_preserves_existing_binding_semantics() -> Result<()> { + let ctx = LiteTableContext::create().await?; + + for sql in [ + "SELECT 'a' LIKE 'a' ESCAPE ''", + "SELECT 'a' LIKE concat('a') ESCAPE ''", + "SELECT '%' LIKE '\\\\%' ESCAPE ''", + "SELECT like_any('%', '\\\\%', '')", + "SELECT 'a' LIKE ANY ('a', 'b') ESCAPE ''", + "SELECT 'a' LIKE ANY (SELECT 'a') ESCAPE ''", + ] { + ctx.bind_sql(sql).await?; + } + + Ok(()) +} + async fn setup_tables(ctx: &Arc, case: &TestCase) -> Result<()> { for sql in case.tables.values() { for statement in sql.split(';').filter(|s| !s.trim().is_empty()) { diff --git a/tests/sqllogictests/suites/query/issues/issue_19562.test b/tests/sqllogictests/suites/query/issues/issue_19562.test new file mode 100644 index 0000000000000..15e6dddc1b959 --- /dev/null +++ b/tests/sqllogictests/suites/query/issues/issue_19562.test @@ -0,0 +1,44 @@ +# GitHub issue: https://github.com/databendlabs/databend/issues/19562 + +query B +SELECT 'a' LIKE 'a' ESCAPE '' +---- +1 + +query B +SELECT 'a' LIKE concat('a') ESCAPE '' +---- +1 + +query B +SELECT '%' LIKE '\\\\%' ESCAPE '' +---- +1 + +query B +SELECT like_any('%', '\\\\%', '') +---- +1 + +query T +EXPLAIN SELECT * FROM numbers(1) WHERE to_string(number) LIKE '\\\\%' ESCAPE '' +---- +Filter +├── output columns: [numbers.number (#0)] +├── filters: [like(CAST(numbers.number (#0) AS String), '\\\\%', '')] +├── estimated rows: 1.00 +└── TableScan + ├── table: default.system.numbers + ├── scan id: 0 + ├── output columns: [number (#0)] + ├── read rows: 1 + ├── read size: < 1 KiB + ├── partitions total: 1 + ├── partitions scanned: 1 + ├── push downs: [filters: [like(CAST(numbers.number (#0) AS String), '\\\\%', '')], limit: NONE] + └── estimated rows: 1.00 + +query B +SELECT 'a' LIKE ANY (SELECT 'a') ESCAPE '' +---- +1