From eb664dba4c5cda7e36b33b40960349f223747aa8 Mon Sep 17 00:00:00 2001 From: LRcoding <38545827+LwClick@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:15:01 +0800 Subject: [PATCH] fix(core): support fuzzy table search --- crates/dbx-core/src/db/mysql.rs | 35 ++++++++++++-- crates/dbx-core/src/db/postgres.rs | 35 ++++++++++++-- crates/dbx-core/src/db/sqlserver.rs | 33 ++++++++++++- crates/dbx-core/src/schema.rs | 33 ++++++++++++- crates/dbx-core/src/sql.rs | 72 ++++++++++++++++++++++++++++- 5 files changed, 197 insertions(+), 11 deletions(-) diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index ad0177b787..dcfb8acc67 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -1113,7 +1113,7 @@ fn filter_list_tables_fallback( offset: Option, object_types: Option<&[String]>, ) -> Vec { - let filter = filter.unwrap_or("").trim().to_ascii_lowercase(); + let filter = filter.unwrap_or("").trim(); let normalized_object_types: Vec = object_types .unwrap_or(&[]) .iter() @@ -1126,7 +1126,7 @@ fn filter_list_tables_fallback( tables .into_iter() - .filter(|table| filter.is_empty() || table.name.to_ascii_lowercase().contains(&filter)) + .filter(|table| crate::sql::contains_or_fuzzy_match(&table.name, filter)) .filter(|table| if table.table_type.eq_ignore_ascii_case("VIEW") { wants_view } else { wants_table }) .skip(offset.unwrap_or(0)) .take(limit.unwrap_or(usize::MAX)) @@ -1163,7 +1163,18 @@ fn list_tables_sql( if let Some(filter) = filter.map(str::trim).filter(|filter| !filter.is_empty()) { let escaped = filter.to_ascii_lowercase().replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); let pattern = format!("%{}%", escaped); - sql.push_str(&format!(" AND LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\'", quote_value(&pattern))); + if crate::sql::fuzzy_filter_enabled(filter) { + let fuzzy_pattern = crate::sql::fuzzy_like_pattern_with_escape(&filter.to_ascii_lowercase(), |value| { + value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") + }); + sql.push_str(&format!( + " AND (LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\' OR LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\')", + quote_value(&pattern), + quote_value(&fuzzy_pattern) + )); + } else { + sql.push_str(&format!(" AND LOWER(TABLE_NAME) LIKE {} ESCAPE '\\\\'", quote_value(&pattern))); + } } sql.push_str(" ORDER BY TABLE_NAME"); if let Some(limit) = limit { @@ -2580,11 +2591,29 @@ mod tests { assert!(sql.contains("FROM information_schema.TABLES")); assert!(sql.contains("TABLE_SCHEMA = 'app'")); assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%user\\\\_\\\\%%' ESCAPE '\\\\'")); + assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%u%s%e%r%\\\\_%\\\\%%' ESCAPE '\\\\'")); assert!(sql.contains("ORDER BY TABLE_NAME")); assert!(sql.contains("LIMIT 101")); assert!(sql.contains("OFFSET 200")); } + #[test] + fn mysql_list_tables_sql_adds_fuzzy_filter_pattern() { + let sql = list_tables_sql("app", Some("sysu"), Some(100), None, None); + + assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%sysu%' ESCAPE '\\\\'")); + assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%s%y%s%u%' ESCAPE '\\\\'")); + } + + #[test] + fn mysql_list_tables_sql_skips_fuzzy_filter_for_single_character() { + let sql = list_tables_sql("app", Some("u"), Some(100), None, None); + + assert!(sql.contains("LOWER(TABLE_NAME) LIKE '%u%' ESCAPE '\\\\'")); + assert_eq!(sql.matches("LOWER(TABLE_NAME) LIKE").count(), 1); + assert!(!sql.contains(" OR LOWER(TABLE_NAME) LIKE")); + } + #[test] fn mysql_list_tables_sql_filters_table_type_before_pagination() { let tables = vec!["TABLE".to_string()]; diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 8d3726ccb1..96f5eda807 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -1170,13 +1170,16 @@ pub async fn list_tables_filtered( offset: Option, ) -> Result, String> { let schema = if schema.is_empty() { "public" } else { schema }; - let filter_pattern = like_contains_pattern(filter.unwrap_or("").trim()); + let filter = filter.unwrap_or("").trim(); + let filter_pattern = like_contains_pattern(filter); + let fuzzy_filter_pattern = + if crate::sql::fuzzy_filter_enabled(filter) { like_fuzzy_pattern(filter) } else { String::new() }; let limit_param = limit.and_then(|value| i64::try_from(value).ok()); let offset_param = offset.and_then(|value| i64::try_from(value).ok()).unwrap_or(0); let client = pool.get().await.map_err(|e| e.to_string())?; let stmt = client.prepare_cached(postgres_tables_sql()).await.map_err(|e| e.to_string())?; let rows = client - .query(&stmt, &[&schema, &filter_pattern, &limit_param, &offset_param]) + .query(&stmt, &[&schema, &filter_pattern, &fuzzy_filter_pattern, &limit_param, &offset_param]) .await .map_err(|e| e.to_string())?; @@ -1415,9 +1418,9 @@ fn postgres_tables_sql() -> &'static str { LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \ LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \ WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \ - AND ($2 = '%%' OR c.relname ILIKE $2 ESCAPE '~') \ + AND ($2 = '%%' OR c.relname ILIKE $2 ESCAPE '~' OR ($3 <> '' AND c.relname ILIKE $3 ESCAPE '~')) \ ORDER BY c.relname \ - LIMIT $3 OFFSET $4" + LIMIT $4 OFFSET $5" } fn like_contains_pattern(value: &str) -> String { @@ -1437,6 +1440,19 @@ fn like_contains_pattern(value: &str) -> String { pattern } +fn like_fuzzy_pattern(value: &str) -> String { + crate::sql::fuzzy_like_pattern_with_escape(value, |value| { + let mut escaped = String::with_capacity(value.len() + 1); + for ch in value.chars() { + if ch == '~' || ch == '%' || ch == '_' { + escaped.push('~'); + } + escaped.push(ch); + } + escaped + }) +} + fn list_objects_sql(include_timestamps: bool) -> &'static str { if include_timestamps { return "SELECT c.relname AS object_name, \ @@ -3024,11 +3040,22 @@ mod tests { assert_eq!(like_contains_pattern(r"foo\bar"), r"%foo\bar%"); } + #[test] + fn like_fuzzy_pattern_escapes_wildcards() { + assert_eq!(like_fuzzy_pattern(""), "%%"); + assert_eq!(like_fuzzy_pattern("sysu"), "%s%y%s%u%"); + assert_eq!(like_fuzzy_pattern("user_%"), "%u%s%e%r%~_%~%%"); + assert_eq!(like_fuzzy_pattern("tilde~name"), "%t%i%l%d%e%~~%n%a%m%e%"); + } + #[test] fn postgres_tables_sql_uses_non_backslash_like_escape() { let sql = postgres_tables_sql(); assert!(sql.contains("ILIKE $2 ESCAPE '~'")); + assert!(sql.contains("$3 <> ''")); + assert!(sql.contains("ILIKE $3 ESCAPE '~'")); + assert!(sql.contains("LIMIT $4 OFFSET $5")); } #[test] diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 21492dff72..c5d1e603ef 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -1117,7 +1117,18 @@ fn sqlserver_list_tables_sql( ) -> String { let filter_clause = filter .filter(|value| !value.trim().is_empty()) - .map(|value| format!(" AND LOWER(o.name) LIKE LOWER('%{}%') ESCAPE '\\' ", escape_like_literal(value.trim()))) + .map(|value| { + let contains_pattern = format!("%{}%", escape_like_literal(value.trim())); + if crate::sql::fuzzy_filter_enabled(value) { + let fuzzy_pattern = + crate::sql::fuzzy_like_pattern_with_escape(value.trim(), escape_like_literal); + format!( + " AND (LOWER(o.name) LIKE LOWER('{contains_pattern}') ESCAPE '\\' OR LOWER(o.name) LIKE LOWER('{fuzzy_pattern}') ESCAPE '\\') " + ) + } else { + format!(" AND LOWER(o.name) LIKE LOWER('{contains_pattern}') ESCAPE '\\' ") + } + }) .unwrap_or_default(); let schema_escaped = schema.replace('\'', "''"); let base_columns = "o.name, CASE WHEN o.type = 'V' THEN 'VIEW' ELSE 'BASE TABLE' END, ep.value AS TABLE_COMMENT"; @@ -1914,6 +1925,7 @@ mod tests { let sql = sqlserver_list_tables_sql("dbo", Some("temp"), Some(200), None); assert!(sql.contains("LOWER(o.name) LIKE LOWER('%temp%') ESCAPE '\\'")); + assert!(sql.contains("LOWER(o.name) LIKE LOWER('%t%e%m%p%') ESCAPE '\\'")); assert!(sql.contains("SELECT TOP (200)")); } @@ -1922,6 +1934,25 @@ mod tests { let sql = sqlserver_list_tables_sql("dbo", Some("Temp_Table[%]"), Some(200), None); assert!(sql.contains("LOWER(o.name) LIKE LOWER('%Temp\\_Table\\[\\%]%') ESCAPE '\\'")); + assert!(sql.contains("LOWER(o.name) LIKE LOWER('%T%e%m%p%\\_%T%a%b%l%e%\\[%\\%%]%') ESCAPE '\\'")); + } + + #[test] + fn sqlserver_list_tables_filter_adds_fuzzy_pattern() { + let sql = sqlserver_list_tables_sql("dbo", Some("sysu"), Some(200), None); + + assert!(sql.contains("LOWER(o.name) LIKE LOWER('%sysu%') ESCAPE '\\'")); + assert!(sql.contains("LOWER(o.name) LIKE LOWER('%s%y%s%u%') ESCAPE '\\'")); + assert!(sql.contains("SELECT TOP (200)")); + } + + #[test] + fn sqlserver_list_tables_filter_skips_fuzzy_pattern_for_single_character() { + let sql = sqlserver_list_tables_sql("dbo", Some("u"), Some(200), None); + + assert!(sql.contains("LOWER(o.name) LIKE LOWER('%u%') ESCAPE '\\'")); + assert!(!sql.contains(" OR LOWER(o.name) LIKE")); + assert!(sql.contains("SELECT TOP (200)")); } #[test] diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 122d9af67d..e18134991a 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1086,12 +1086,12 @@ fn filter_table_infos( offset: Option, object_types: Option<&[String]>, ) -> Vec { - let filter = filter.unwrap_or("").to_lowercase(); + let filter = filter.unwrap_or(""); let limit = limit.unwrap_or(usize::MAX); let offset = offset.unwrap_or(0); tables .into_iter() - .filter(|table| filter.is_empty() || table.name.to_lowercase().contains(&filter)) + .filter(|table| crate::sql::contains_or_fuzzy_match(&table.name, filter)) .filter(|table| table_info_matches_object_types(table, object_types)) .skip(offset) .take(limit) @@ -1431,6 +1431,35 @@ mod tests { assert_eq!(filtered[0].name, "audit_record"); } + #[test] + fn filter_table_infos_matches_fuzzy_subsequences() { + let tables = vec![test_table_info("system_user"), test_table_info("user_order"), test_table_info("alpha")]; + + let system_user = filter_table_infos(tables.clone(), Some("sysu"), None, None, None); + assert_eq!(system_user.into_iter().map(|table| table.name).collect::>(), vec!["system_user"]); + + let user_order = filter_table_infos(tables, Some("uo"), None, None, None); + assert_eq!(user_order.into_iter().map(|table| table.name).collect::>(), vec!["user_order"]); + } + + #[test] + fn filter_table_infos_skips_fuzzy_for_single_character_filters() { + let tables = vec![test_table_info("orders"), test_table_info("user_order")]; + + let filtered = filter_table_infos(tables, Some("u"), None, None, None); + + assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["user_order"]); + } + + #[test] + fn filter_table_infos_keeps_special_filter_characters_literal() { + let tables = vec![test_table_info("user_%"), test_table_info("user_account"), test_table_info("userXpercent")]; + + let filtered = filter_table_infos(tables, Some("user_%"), None, None, None); + + assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["user_%"]); + } + #[test] fn filter_table_infos_filters_object_type_before_offset_and_limit() { let tables = vec![ diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index defa17ce58..d272e722ac 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -4,6 +4,53 @@ use sqlparser::tokenizer::{Token, Tokenizer}; use crate::models::connection::DatabaseType; +pub const MIN_FUZZY_FILTER_CHARS: usize = 2; + +pub fn fuzzy_filter_enabled(filter: &str) -> bool { + filter.trim().chars().count() >= MIN_FUZZY_FILTER_CHARS +} + +pub fn fuzzy_subsequence_match(text: &str, filter: &str) -> bool { + let filter = filter.trim().to_lowercase(); + if filter.is_empty() { + return true; + } + + let text = text.to_lowercase(); + let mut chars = text.chars(); + for needle in filter.chars() { + if !chars.any(|candidate| candidate == needle) { + return false; + } + } + true +} + +pub fn contains_or_fuzzy_match(text: &str, filter: &str) -> bool { + let filter = filter.trim().to_lowercase(); + if filter.is_empty() { + return true; + } + + let text = text.to_lowercase(); + text.contains(&filter) || (fuzzy_filter_enabled(&filter) && fuzzy_subsequence_match(&text, &filter)) +} + +pub fn fuzzy_like_pattern_with_escape(value: &str, mut escape: impl FnMut(&str) -> String) -> String { + let value = value.trim(); + if value.is_empty() { + return "%%".to_string(); + } + + let mut pattern = String::with_capacity(value.len() * 2 + 2); + pattern.push('%'); + for ch in value.chars() { + pattern.push_str(&escape(&ch.to_string())); + pattern.push('%'); + } + pattern +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqlFileRequest { @@ -1973,12 +2020,35 @@ mod tests { use crate::models::connection::DatabaseType; use super::{ - decode_sql_file_bytes, find_statement_at_cursor_for_database, optimize_sql_file_import_statements, + contains_or_fuzzy_match, decode_sql_file_bytes, find_statement_at_cursor_for_database, fuzzy_filter_enabled, + fuzzy_like_pattern_with_escape, fuzzy_subsequence_match, optimize_sql_file_import_statements, prepare_sql_file_statement, split_sql_script, split_sql_statements_for_database, starts_with_executable_sql_keyword, starts_with_executable_sql_keyword_for_database, SqlDialectProfile, SqlFileStatementAction, SqlStatementSplitter, }; + #[test] + fn fuzzy_subsequence_match_matches_ordered_characters() { + assert!(fuzzy_subsequence_match("system_user", "sysu")); + assert!(contains_or_fuzzy_match("user_order", "uo")); + assert!(!contains_or_fuzzy_match("alpha", "uo")); + } + + #[test] + fn contains_or_fuzzy_match_skips_fuzzy_for_single_character_filters() { + assert!(fuzzy_filter_enabled("uo")); + assert!(!fuzzy_filter_enabled("u")); + assert!(contains_or_fuzzy_match("user_order", "u")); + assert!(!contains_or_fuzzy_match("orders", "u")); + } + + #[test] + fn fuzzy_like_pattern_with_escape_keeps_wildcards_literal() { + let pattern = fuzzy_like_pattern_with_escape("user_%", |value| value.replace('%', "\\%").replace('_', "\\_")); + + assert_eq!(pattern, "%u%s%e%r%\\_%\\%%"); + } + #[test] fn splits_semicolon_delimited_statements() { assert_eq!(