fix(core): support fuzzy table search

This commit is contained in:
LRcoding
2026-06-26 13:15:01 +08:00
committed by GitHub
parent d6052cff8b
commit eb664dba4c
5 changed files with 197 additions and 11 deletions
+32 -3
View File
@@ -1113,7 +1113,7 @@ fn filter_list_tables_fallback(
offset: Option<usize>,
object_types: Option<&[String]>,
) -> Vec<TableInfo> {
let filter = filter.unwrap_or("").trim().to_ascii_lowercase();
let filter = filter.unwrap_or("").trim();
let normalized_object_types: Vec<String> = 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()];
+31 -4
View File
@@ -1170,13 +1170,16 @@ pub async fn list_tables_filtered(
offset: Option<usize>,
) -> Result<Vec<TableInfo>, 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]
+32 -1
View File
@@ -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]
+31 -2
View File
@@ -1086,12 +1086,12 @@ fn filter_table_infos(
offset: Option<usize>,
object_types: Option<&[String]>,
) -> Vec<db::TableInfo> {
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<_>>(), 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<_>>(), 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<_>>(), 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<_>>(), vec!["user_%"]);
}
#[test]
fn filter_table_infos_filters_object_type_before_offset_and_limit() {
let tables = vec![
+71 -1
View File
@@ -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!(