mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-24 12:11:23 +00:00
fix(core): support fuzzy table search
This commit is contained in:
@@ -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()];
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user