From 63f2779b9ce1f699960a8167de3f00d9f543fc40 Mon Sep 17 00:00:00 2001 From: sundyli <543950155@qq.com> Date: Mon, 23 Mar 2026 20:32:47 +0800 Subject: [PATCH 1/6] fix(query): handle empty LIKE ESCAPE in planner (#19562) --- src/query/sql/src/planner/semantic/type_check.rs | 14 ++++++-------- src/query/sql/tests/it/planner.rs | 9 +++++++++ .../suites/query/issues/issue_19562.test | 6 ++++++ 3 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 tests/sqllogictests/suites/query/issues/issue_19562.test diff --git a/src/query/sql/src/planner/semantic/type_check.rs b/src/query/sql/src/planner/semantic/type_check.rs index 5553a7be5f5f0..6b2f590f9ea0a 100644 --- a/src/query/sql/src/planner/semantic/type_check.rs +++ b/src/query/sql/src/planner/semantic/type_check.rs @@ -5620,14 +5620,12 @@ 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 = + if let Some(escape_char) = escape.as_ref().and_then(|escape| escape.chars().next()) { + Cow::Owned(convert_escape_pattern(like_str, escape_char)) + } else { + Cow::Borrowed(like_str) + }; if check_percent(&new_like_str) { // Convert to `a is not null` let is_not_null = Expr::IsNull { diff --git a/src/query/sql/tests/it/planner.rs b/src/query/sql/tests/it/planner.rs index 1df4cf2012d30..619a9b64ca98f 100644 --- a/src/query/sql/tests/it/planner.rs +++ b/src/query/sql/tests/it/planner.rs @@ -143,6 +143,15 @@ async fn test_lite_replay_service_optimizer_cases() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_bind_like_with_empty_escape() -> Result<()> { + let ctx = LiteTableContext::create().await?; + + ctx.bind_sql("SELECT 'a' LIKE 'a' ESCAPE ''").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..7a602161cb480 --- /dev/null +++ b/tests/sqllogictests/suites/query/issues/issue_19562.test @@ -0,0 +1,6 @@ +# GitHub issue: https://github.com/databendlabs/databend/issues/19562 + +query B +SELECT 'a' LIKE 'a' ESCAPE '' +---- +1 From fe8c2a63a1e43bb41b7248f28644a561bec28011 Mon Sep 17 00:00:00 2001 From: sundyli <543950155@qq.com> Date: Wed, 25 Mar 2026 04:38:21 +0800 Subject: [PATCH 2/6] fix(query): validate LIKE ESCAPE literals (#19562) --- .../sql/src/planner/semantic/type_check.rs | 45 ++++++++++++++++--- src/query/sql/tests/it/planner.rs | 20 ++++++++- .../suites/query/issues/issue_19562.test | 7 +-- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/query/sql/src/planner/semantic/type_check.rs b/src/query/sql/src/planner/semantic/type_check.rs index 6b2f590f9ea0a..a32215644addb 100644 --- a/src/query/sql/src/planner/semantic/type_check.rs +++ b/src/query/sql/src/planner/semantic/type_check.rs @@ -1607,6 +1607,15 @@ impl<'a> TypeChecker<'a> { modifier: &SubqueryModifier, op: &BinaryOperator, ) -> Result> { + match op { + BinaryOperator::Like(escape) + | BinaryOperator::LikeAny(escape) + | BinaryOperator::NotLike(escape) => { + Self::validate_like_escape(*span, escape)?; + } + _ => {} + } + Ok(match modifier { SubqueryModifier::Any | SubqueryModifier::Some => { let comparison_op = SubqueryComparisonOp::try_from(op)?; @@ -5620,12 +5629,12 @@ impl<'a> TypeChecker<'a> { like_str: &str, escape: &Option, ) -> Result> { - let new_like_str = - if let Some(escape_char) = escape.as_ref().and_then(|escape| escape.chars().next()) { - Cow::Owned(convert_escape_pattern(like_str, escape_char)) - } else { - Cow::Borrowed(like_str) - }; + let escape_char = Self::validate_like_escape(span, escape)?; + let new_like_str = if let Some(escape_char) = escape_char { + Cow::Owned(convert_escape_pattern(like_str, escape_char)) + } else { + Cow::Borrowed(like_str) + }; if check_percent(&new_like_str) { // Convert to `a is not null` let is_not_null = Expr::IsNull { @@ -5668,6 +5677,7 @@ impl<'a> TypeChecker<'a> { right: &Expr, escape: &Option, ) -> Result> { + Self::validate_like_escape(span, escape)?; let name = op.to_func_name(); let escape_expr = escape.as_ref().map(|escape| Expr::Literal { span, @@ -5680,6 +5690,29 @@ impl<'a> TypeChecker<'a> { self.resolve_function(span, name.as_str(), vec![], &arguments) } + fn validate_like_escape(span: Span, escape: &Option) -> Result> { + let Some(escape) = escape else { + return Ok(None); + }; + + let mut chars = escape.chars(); + let Some(first) = chars.next() else { + return Err(ErrorCode::SemanticError( + "LIKE ESCAPE expression must be a single character".to_string(), + ) + .set_span(span)); + }; + + if chars.next().is_some() { + return Err(ErrorCode::SemanticError( + "LIKE ESCAPE expression must be a single character".to_string(), + ) + .set_span(span)); + } + + Ok(Some(first)) + } + fn resolve_udf( &mut self, span: Span, diff --git a/src/query/sql/tests/it/planner.rs b/src/query/sql/tests/it/planner.rs index 619a9b64ca98f..4815a57d1894b 100644 --- a/src/query/sql/tests/it/planner.rs +++ b/src/query/sql/tests/it/planner.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use databend_common_catalog::table_context::TableContext; +use databend_common_exception::ErrorCode; use databend_common_exception::Result; use databend_common_sql_test_support::TestCase; use databend_common_sql_test_support::TestCaseRunner; @@ -144,10 +145,25 @@ async fn test_lite_replay_service_optimizer_cases() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_bind_like_with_empty_escape() -> Result<()> { +async fn test_like_escape_rejects_non_single_character_literal() -> Result<()> { let ctx = LiteTableContext::create().await?; - ctx.bind_sql("SELECT 'a' LIKE 'a' ESCAPE ''").await?; + for sql in [ + "SELECT 'a' LIKE 'a' ESCAPE ''", + "SELECT 'a' LIKE 'a' ESCAPE 'ab'", + "SELECT 'a' LIKE ANY ('a', 'b') ESCAPE ''", + "SELECT 'a' LIKE ANY (SELECT 'a') ESCAPE ''", + "SELECT 'a' LIKE ANY (SELECT 'a') ESCAPE 'ab'", + ] { + let err = ctx.bind_sql(sql).await.unwrap_err(); + assert_eq!(err.code(), ErrorCode::SEMANTIC_ERROR); + assert!( + err.message() + .contains("LIKE ESCAPE expression must be a single character"), + "unexpected error for `{sql}`: {}", + err.message() + ); + } Ok(()) } diff --git a/tests/sqllogictests/suites/query/issues/issue_19562.test b/tests/sqllogictests/suites/query/issues/issue_19562.test index 7a602161cb480..751c48bd6ecd1 100644 --- a/tests/sqllogictests/suites/query/issues/issue_19562.test +++ b/tests/sqllogictests/suites/query/issues/issue_19562.test @@ -1,6 +1,7 @@ # GitHub issue: https://github.com/databendlabs/databend/issues/19562 -query B +statement error 1065 SELECT 'a' LIKE 'a' ESCAPE '' ----- -1 + +statement error 1065 +SELECT 'a' LIKE 'a' ESCAPE 'ab' From ed557bd37426095ec4f0585df023ebf9351fb206 Mon Sep 17 00:00:00 2001 From: sundyli <543950155@qq.com> Date: Wed, 25 Mar 2026 09:17:56 +0800 Subject: [PATCH 3/6] fix(query): preserve LIKE ESCAPE fallback semantics --- .../sql/src/planner/semantic/type_check.rs | 54 ++++++------------- src/query/sql/tests/it/planner.rs | 15 ++---- .../suites/query/issues/issue_19562.test | 33 ++++++++++-- 3 files changed, 49 insertions(+), 53 deletions(-) diff --git a/src/query/sql/src/planner/semantic/type_check.rs b/src/query/sql/src/planner/semantic/type_check.rs index a32215644addb..1a00d012544a5 100644 --- a/src/query/sql/src/planner/semantic/type_check.rs +++ b/src/query/sql/src/planner/semantic/type_check.rs @@ -1607,15 +1607,6 @@ impl<'a> TypeChecker<'a> { modifier: &SubqueryModifier, op: &BinaryOperator, ) -> Result> { - match op { - BinaryOperator::Like(escape) - | BinaryOperator::LikeAny(escape) - | BinaryOperator::NotLike(escape) => { - Self::validate_like_escape(*span, escape)?; - } - _ => {} - } - Ok(match modifier { SubqueryModifier::Any | SubqueryModifier::Some => { let comparison_op = SubqueryComparisonOp::try_from(op)?; @@ -5629,11 +5620,22 @@ impl<'a> TypeChecker<'a> { like_str: &str, escape: &Option, ) -> Result> { - let escape_char = Self::validate_like_escape(span, escape)?; - let new_like_str = if let Some(escape_char) = escape_char { - Cow::Owned(convert_escape_pattern(like_str, escape_char)) - } 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` @@ -5677,7 +5679,6 @@ impl<'a> TypeChecker<'a> { right: &Expr, escape: &Option, ) -> Result> { - Self::validate_like_escape(span, escape)?; let name = op.to_func_name(); let escape_expr = escape.as_ref().map(|escape| Expr::Literal { span, @@ -5690,29 +5691,6 @@ impl<'a> TypeChecker<'a> { self.resolve_function(span, name.as_str(), vec![], &arguments) } - fn validate_like_escape(span: Span, escape: &Option) -> Result> { - let Some(escape) = escape else { - return Ok(None); - }; - - let mut chars = escape.chars(); - let Some(first) = chars.next() else { - return Err(ErrorCode::SemanticError( - "LIKE ESCAPE expression must be a single character".to_string(), - ) - .set_span(span)); - }; - - if chars.next().is_some() { - return Err(ErrorCode::SemanticError( - "LIKE ESCAPE expression must be a single character".to_string(), - ) - .set_span(span)); - } - - Ok(Some(first)) - } - fn resolve_udf( &mut self, span: Span, diff --git a/src/query/sql/tests/it/planner.rs b/src/query/sql/tests/it/planner.rs index 4815a57d1894b..e80c7d23e8914 100644 --- a/src/query/sql/tests/it/planner.rs +++ b/src/query/sql/tests/it/planner.rs @@ -15,7 +15,6 @@ use std::sync::Arc; use databend_common_catalog::table_context::TableContext; -use databend_common_exception::ErrorCode; use databend_common_exception::Result; use databend_common_sql_test_support::TestCase; use databend_common_sql_test_support::TestCaseRunner; @@ -145,24 +144,16 @@ async fn test_lite_replay_service_optimizer_cases() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_like_escape_rejects_non_single_character_literal() -> Result<()> { +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 'a' ESCAPE 'ab'", + "SELECT 'a' LIKE concat('a') ESCAPE ''", "SELECT 'a' LIKE ANY ('a', 'b') ESCAPE ''", "SELECT 'a' LIKE ANY (SELECT 'a') ESCAPE ''", - "SELECT 'a' LIKE ANY (SELECT 'a') ESCAPE 'ab'", ] { - let err = ctx.bind_sql(sql).await.unwrap_err(); - assert_eq!(err.code(), ErrorCode::SEMANTIC_ERROR); - assert!( - err.message() - .contains("LIKE ESCAPE expression must be a single character"), - "unexpected error for `{sql}`: {}", - err.message() - ); + ctx.bind_sql(sql).await?; } Ok(()) diff --git a/tests/sqllogictests/suites/query/issues/issue_19562.test b/tests/sqllogictests/suites/query/issues/issue_19562.test index 751c48bd6ecd1..20f7015a59ab0 100644 --- a/tests/sqllogictests/suites/query/issues/issue_19562.test +++ b/tests/sqllogictests/suites/query/issues/issue_19562.test @@ -1,7 +1,34 @@ # GitHub issue: https://github.com/databendlabs/databend/issues/19562 -statement error 1065 +query B SELECT 'a' LIKE 'a' ESCAPE '' +---- +1 -statement error 1065 -SELECT 'a' LIKE 'a' ESCAPE 'ab' +query B +SELECT 'a' LIKE concat('a') ESCAPE '' +---- +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 From dca9d10dc4ed7fd7cc54c224a3b296fc492eee2f Mon Sep 17 00:00:00 2001 From: sundyli <543950155@qq.com> Date: Wed, 25 Mar 2026 09:29:55 +0800 Subject: [PATCH 4/6] test(query): cover empty LIKE ESCAPE fallback (#19562) --- src/query/sql/tests/it/planner.rs | 2 ++ .../sqllogictests/suites/query/issues/issue_19562.test | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/query/sql/tests/it/planner.rs b/src/query/sql/tests/it/planner.rs index e80c7d23e8914..67a080b4e51a6 100644 --- a/src/query/sql/tests/it/planner.rs +++ b/src/query/sql/tests/it/planner.rs @@ -150,6 +150,8 @@ async fn test_like_escape_preserves_existing_binding_semantics() -> Result<()> { 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 ''", ] { diff --git a/tests/sqllogictests/suites/query/issues/issue_19562.test b/tests/sqllogictests/suites/query/issues/issue_19562.test index 20f7015a59ab0..15e6dddc1b959 100644 --- a/tests/sqllogictests/suites/query/issues/issue_19562.test +++ b/tests/sqllogictests/suites/query/issues/issue_19562.test @@ -10,6 +10,16 @@ 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 '' ---- From 8186083a3b8be7ecd162f392c465a4327fb02ef4 Mon Sep 17 00:00:00 2001 From: sundyli <543950155@qq.com> Date: Sun, 5 Apr 2026 08:27:44 +0000 Subject: [PATCH 5/6] test(query): align empty LIKE ESCAPE regression output --- tests/sqllogictests/suites/query/issues/issue_19562.test | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sqllogictests/suites/query/issues/issue_19562.test b/tests/sqllogictests/suites/query/issues/issue_19562.test index 15e6dddc1b959..f07a691116f00 100644 --- a/tests/sqllogictests/suites/query/issues/issue_19562.test +++ b/tests/sqllogictests/suites/query/issues/issue_19562.test @@ -13,12 +13,12 @@ SELECT 'a' LIKE concat('a') ESCAPE '' query B SELECT '%' LIKE '\\\\%' ESCAPE '' ---- -1 +0 query B SELECT like_any('%', '\\\\%', '') ---- -1 +0 query T EXPLAIN SELECT * FROM numbers(1) WHERE to_string(number) LIKE '\\\\%' ESCAPE '' From abf960a21a6a16f9737bcc10beef479eae0bb713 Mon Sep 17 00:00:00 2001 From: sundyli <543950155@qq.com> Date: Sun, 5 Apr 2026 11:58:12 +0000 Subject: [PATCH 6/6] test(query): remove cluster-unstable explain assertion --- .../suites/query/issues/issue_19562.test | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/sqllogictests/suites/query/issues/issue_19562.test b/tests/sqllogictests/suites/query/issues/issue_19562.test index f07a691116f00..135b9570363b4 100644 --- a/tests/sqllogictests/suites/query/issues/issue_19562.test +++ b/tests/sqllogictests/suites/query/issues/issue_19562.test @@ -20,24 +20,6 @@ SELECT like_any('%', '\\\\%', '') ---- 0 -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 '' ----