Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,18 @@ dashmap = "5.5.3"
moka = { version = "0.12", features = ["sync"] }
thiserror="2.0.14"
llm-connector = { path = "crates/llm-connector", features = ["streaming"] }
redis_client = { package = "redis", version = "0.27", features = ["tokio-comp", "cluster-async", "connection-manager"] }
# `use_tls` 会拼出 `rediss://` URL,必须启用 rustls 的异步 TLS 支持(tokio-rustls-comp
# 隐含 tokio-comp 与 tls-rustls),否则 `Client::open` 直接返回
# `InvalidClientConfig("can't connect with TLS, the feature is not enabled")`。
# 刻意不启用 `tls-rustls-webpki-roots`:redis-rs 一旦启用它就不再加载系统根证书
# (见 `create_rustls_config`),企业私有 CA 装的根证书会全部失效;保留 native-certs
# 才能同时信任公共 CA 与用户自行安装的私有 CA。
redis_client = { package = "redis", version = "0.27", features = [
"tokio-comp",
"tokio-rustls-comp",
"cluster-async",
"connection-manager",
] }

# SSH and SFTP
# dsa feature enables verifying/offering the legacy ssh-dss host-key algorithm.
Expand Down
6 changes: 6 additions & 0 deletions crates/core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,9 @@ pub struct AppSettings {
pub sql_editor_hover_enabled: bool,
#[serde(default = "default_monospace_font_family")]
pub table_preview_font_family: String,
/// Markdown 编辑器代码字体(行内代码、代码块、表格内代码)。
#[serde(default = "default_monospace_font_family")]
pub markdown_code_font_family: String,
#[serde(default = "default_monospace_font_family")]
pub terminal_font_family: String,
#[serde(
Expand Down Expand Up @@ -1480,6 +1483,7 @@ impl Default for AppSettings {
sql_editor_font_size: default_sql_editor_font_size(),
sql_editor_hover_enabled: default_sql_editor_hover_enabled(),
table_preview_font_family: default_monospace_font_family(),
markdown_code_font_family: default_monospace_font_family(),
terminal_font_family: default_monospace_font_family(),
custom_fonts: Vec::new(),
terminal_font_size: default_terminal_font_size(),
Expand Down Expand Up @@ -1619,6 +1623,8 @@ impl AppSettings {
normalize_grid_monospace_font_family(&self.sql_editor_font_family);
self.table_preview_font_family =
normalize_grid_monospace_font_family(&self.table_preview_font_family);
self.markdown_code_font_family =
normalize_grid_monospace_font_family(&self.markdown_code_font_family);
self.terminal_font_family =
normalize_grid_monospace_font_family(&self.terminal_font_family);
}
Expand Down
14 changes: 14 additions & 0 deletions crates/db/src/mysql/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,20 @@ impl DatabasePlugin for MySqlPlugin {
mysql_quote_identifier(identifier)
}

/// MySQL 没有 schema 层级,`库.表` 里的前缀就是数据库名(见 `qualified_table_parts`)。
fn resolve_metadata_table_identity(
&self,
database: &str,
schema: Option<&str>,
table: &str,
) -> (String, Option<String>, String) {
let schema = schema.map(str::to_string);
let Some((prefix, bare_table)) = crate::plugin::qualified_table_parts(table) else {
return (database.to_string(), schema, table.to_string());
};
(prefix.to_string(), schema, bare_table.to_string())
}

fn get_completion_info(&self) -> SqlCompletionInfo {
SqlCompletionInfo {
keywords: vec![
Expand Down
183 changes: 180 additions & 3 deletions crates/db/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2101,6 +2101,57 @@ pub trait DatabasePlugin: Send + Sync {
)
}

/// 引用一个可能已带限定前缀的表名(`库.表` / `schema.table` / `库.schema.表`),逐段加引号。
fn quote_qualified_table_name(&self, name: &str) -> String {
quote_each_identifier(name, |part| self.quote_identifier(part))
}

/// 与 [`Self::format_table_reference`] 相同,但允许 `table` 本身已经是限定名。
///
/// SQL 结果网格里的表名来自查询分析(见 `direct_table_identity`),是 `库.表`
/// 的拼接形式。这类名字已自带限定前缀,再拼一次 database 会得到
/// `` `user_center`.`user_center.uc_user_info` `` 这种非法引用(MySQL 报 1146),
/// 所以限定名按段引用后直接使用;裸表名仍走 [`Self::format_table_reference`]。
fn format_resolved_table_reference(
&self,
database: &str,
schema: Option<&str>,
table: &str,
) -> String {
if qualified_table_parts(table).is_some() {
self.quote_qualified_table_name(table)
} else {
self.format_table_reference(database, schema, table)
}
}

/// 解析元数据查询(列 / 索引)所需的表身份。
///
/// 查询分析给出的表名可能是 `库.表` / `schema.table`,元数据查询必须拆成
/// 三段才能命中系统表,否则拿不到主键与索引信息。默认把前缀当作 schema;
/// 没有 schema 层级的方言(MySQL)需覆盖为 database。
fn resolve_metadata_table_identity(
&self,
database: &str,
schema: Option<&str>,
table: &str,
) -> (String, Option<String>, String) {
let Some((prefix, bare_table)) = qualified_table_parts(table) else {
return (
database.to_string(),
schema.map(str::to_string),
table.to_string(),
);
};
(
database.to_string(),
schema
.map(str::to_string)
.or_else(|| Some(prefix.to_string())),
bare_table.to_string(),
)
}

/// Format table reference for SQL export output (omit database, keep schema when present).
fn format_export_table_reference(
&self,
Expand Down Expand Up @@ -2531,7 +2582,7 @@ pub trait DatabasePlugin: Send + Sync {
request: &TableSaveRequest,
change: &TableRowChange,
) -> Option<String> {
let table_ident = self.format_table_reference(
let table_ident = self.format_resolved_table_reference(
&request.database,
request.schema.as_deref(),
&request.table,
Expand Down Expand Up @@ -2607,7 +2658,7 @@ pub trait DatabasePlugin: Send + Sync {
self.build_where_and_limit_clause(request, original_data);

if limit_clause == " __SQLITE_ROWID_LIMIT__" {
let simple_table = self.quote_identifier(&request.table);
let simple_table = self.quote_qualified_table_name(&request.table);
Some(format!(
"UPDATE {} SET {} WHERE rowid IN (SELECT rowid FROM {} WHERE {} LIMIT 1)",
table_ident,
Expand Down Expand Up @@ -2648,7 +2699,7 @@ pub trait DatabasePlugin: Send + Sync {
self.build_where_and_limit_clause(request, original_data);

if limit_clause == " __SQLITE_ROWID_LIMIT__" {
let simple_table = self.quote_identifier(&request.table);
let simple_table = self.quote_qualified_table_name(&request.table);
Some(format!(
"DELETE FROM {} WHERE rowid IN (SELECT rowid FROM {} WHERE {} LIMIT 1)",
table_ident, simple_table, where_clause
Expand Down Expand Up @@ -3616,6 +3667,34 @@ fn direct_table_identity(relation: &TableFactor) -> Option<(String, Option<Strin
))
}

/// 拆分可能带限定前缀的表名,返回 `(限定前缀, 裸表名)`。
///
/// 查询分析(见 `direct_table_identity`)把 `` `库`.`表` `` 拼成 `库.表`,
/// 生成 SQL 与元数据查询时都要能识别这种限定名。
pub(crate) fn qualified_table_parts(name: &str) -> Option<(&str, &str)> {
let (prefix, bare_table) = name.rsplit_once('.')?;
let prefix = prefix.trim();
let bare_table = bare_table.trim();
(!prefix.is_empty() && !bare_table.is_empty()).then_some((prefix, bare_table))
}

/// 按 `.` 拆分并逐段加引号,用于已经是限定名的表引用。
pub(crate) fn quote_each_identifier<F>(name: &str, quote: F) -> String
where
F: Fn(&str) -> String,
{
let parts: Vec<String> = name
.split('.')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(&quote)
.collect();
if parts.is_empty() {
return quote(name);
}
parts.join(".")
}

fn select_projection_is_direct(
select: &ast::Select,
table_name: &str,
Expand Down Expand Up @@ -4613,4 +4692,102 @@ mod tests {
None
);
}

/// 查询结果网格里的表名来自 SQL 分析,是 `库.表` 拼接形式;保存变更时
/// 不能再拼一次 database,否则会生成 `` `user_center`.`user_center.uc_user_info` ``
/// 这种非法引用(MySQL 42S02 / 1146)。
#[test]
fn qualified_table_name_is_not_prefixed_twice_in_save_sql() {
let plugin = MySqlPlugin::new();
let request = TableSaveRequest {
database: "user_center".to_string(),
schema: None,
table: "user_center.uc_user_info".to_string(),
columns: vec![],
index_infos: vec![],
changes: vec![TableRowChange::Updated {
original_data: vec![],
changes: vec![TableCellChange {
column_index: 0,
column_name: "user_type".to_string(),
old_value: TableCellValue::Null,
new_value: TableCellValue::Text("000104".to_string()),
}],
rowid: Some("1".to_string()),
}],
};

assert_eq!(
plugin.generate_table_changes_sql(&request),
"UPDATE `user_center`.`uc_user_info` SET `user_type` = '000104' WHERE rowid = '1';"
);
}

/// 表名自带的限定前缀优先:跨库查询时可能与当前连接的库不同。
#[test]
fn qualified_table_name_wins_over_connection_database() {
let plugin = MySqlPlugin::new();
assert_eq!(
plugin.format_resolved_table_reference("main", None, "other_db.orders"),
"`other_db`.`orders`"
);
assert_eq!(
plugin.format_resolved_table_reference("main", None, "main.orders"),
"`main`.`orders`"
);
}

#[test]
fn bare_table_name_still_uses_database_prefix() {
let plugin = MySqlPlugin::new();
assert_eq!(
plugin.format_resolved_table_reference("user_center", None, "uc_user_info"),
"`user_center`.`uc_user_info`"
);
assert_eq!(
plugin.quote_qualified_table_name("uc_user_info"),
"`uc_user_info`"
);
}

/// 元数据查询要拆成 (database, schema, table),否则命不中系统表,
/// 主键/索引信息会丢失,保存时退化成全列比较。
#[test]
fn mysql_metadata_identity_treats_prefix_as_database() {
let plugin = MySqlPlugin::new();
assert_eq!(
plugin.resolve_metadata_table_identity("user_center", None, "user_center.uc_user_info"),
("user_center".to_string(), None, "uc_user_info".to_string())
);
assert_eq!(
plugin.resolve_metadata_table_identity("main", None, "other_db.orders"),
("other_db".to_string(), None, "orders".to_string())
);
assert_eq!(
plugin.resolve_metadata_table_identity("main", None, "orders"),
("main".to_string(), None, "orders".to_string())
);
}

/// 支持 schema 层级的方言里,`schema.table` 的前缀是 schema。
#[test]
fn schema_dialect_metadata_identity_treats_prefix_as_schema() {
let plugin = crate::postgresql::PostgresPlugin::new();
assert_eq!(
plugin.resolve_metadata_table_identity("mydb", None, "public.orders"),
(
"mydb".to_string(),
Some("public".to_string()),
"orders".to_string()
)
);
assert_eq!(
plugin.resolve_metadata_table_identity("mydb", Some("sales"), "orders"),
(
"mydb".to_string(),
Some("sales".to_string()),
"orders".to_string()
)
);
}
}
67 changes: 67 additions & 0 deletions crates/db/tests/qualified_table_save_repro.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//! 回归测试:查询结果的编辑网格保存变更时,表名不能重复拼接限定前缀。
//!
//! 复现场景:`select * from \`user_center\`.\`uc_user_info\`` 的查询结果网格里
//! 表名来自 SQL 分析,是 `库.表` 的拼接形式;如果生成 SQL 时再拼一次当前连接的
//! 数据库名,会得到 `` `user_center`.`user_center.uc_user_info` ``,
//! MySQL 报 `ERROR 42S02 (1146): Table 'user_center.user_center.uc_user_info' doesn't exist`。
use db::DbManager;
use db::plugin::DatabasePlugin;
use db::types::{TableCellChange, TableCellValue, TableRowChange, TableSaveRequest};
use one_core::storage::DatabaseType;

fn mysql_plugin() -> std::sync::Arc<dyn DatabasePlugin> {
DbManager::default()
.get_plugin(&DatabaseType::MySQL)
.expect("MySQL plugin should exist")
}

fn qualified_table_request(table: &str) -> TableSaveRequest {
TableSaveRequest {
database: "user_center".to_string(),
schema: None,
table: table.to_string(),
columns: vec![],
index_infos: vec![],
changes: vec![TableRowChange::Updated {
original_data: vec![],
changes: vec![TableCellChange {
column_index: 0,
column_name: "user_type".to_string(),
old_value: TableCellValue::Null,
new_value: TableCellValue::Text("000104".to_string()),
}],
rowid: Some("1".to_string()),
}],
}
}

#[test]
fn save_sql_keeps_qualified_table_name_once() {
let plugin = mysql_plugin();
assert_eq!(
plugin.generate_table_changes_sql(&qualified_table_request("user_center.uc_user_info")),
"UPDATE `user_center`.`uc_user_info` SET `user_type` = '000104' WHERE rowid = '1';"
);
}

#[test]
fn save_sql_of_bare_table_name_is_unchanged() {
let plugin = mysql_plugin();
assert_eq!(
plugin.generate_table_changes_sql(&qualified_table_request("uc_user_info")),
"UPDATE `user_center`.`uc_user_info` SET `user_type` = '000104' WHERE rowid = '1';"
);
}

#[test]
fn metadata_identity_uses_prefix_as_database_for_mysql() {
let plugin = mysql_plugin();
assert_eq!(
plugin.resolve_metadata_table_identity("user_center", None, "user_center.uc_user_info"),
("user_center".to_string(), None, "uc_user_info".to_string())
);
assert_eq!(
plugin.resolve_metadata_table_identity("main", None, "uc_user_info"),
("main".to_string(), None, "uc_user_info".to_string())
);
}
Loading
Loading