471 lines
16 KiB
Rust
471 lines
16 KiB
Rust
use crate::models::connection::DatabaseType;
|
|
use percent_encoding::percent_decode_str;
|
|
|
|
use super::capabilities::{is_schema_aware, is_simple_informix_identifier};
|
|
|
|
pub const SQLSERVER_LINKED_SCHEMA_PREFIX: &str = "__dbx_sqlserver_linked__:";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SqlServerLinkedSchemaRef {
|
|
pub server: String,
|
|
pub catalog: String,
|
|
pub schema: String,
|
|
}
|
|
|
|
pub fn parse_sqlserver_linked_schema_ref(schema: &str) -> Option<SqlServerLinkedSchemaRef> {
|
|
let payload = schema.strip_prefix(SQLSERVER_LINKED_SCHEMA_PREFIX)?;
|
|
let mut parts = payload.split('|');
|
|
let server = decode_linked_schema_part(parts.next()?)?;
|
|
let catalog = decode_linked_schema_part(parts.next()?)?;
|
|
let schema = decode_linked_schema_part(parts.next()?)?;
|
|
if parts.next().is_some() || server.trim().is_empty() || catalog.trim().is_empty() || schema.trim().is_empty() {
|
|
return None;
|
|
}
|
|
Some(SqlServerLinkedSchemaRef { server, catalog, schema })
|
|
}
|
|
|
|
pub fn sqlserver_linked_table_name(linked: &SqlServerLinkedSchemaRef, table_name: &str) -> String {
|
|
[linked.server.as_str(), linked.catalog.as_str(), linked.schema.as_str(), table_name]
|
|
.into_iter()
|
|
.map(|part| quote_table_identifier(Some(DatabaseType::SqlServer), part))
|
|
.collect::<Vec<_>>()
|
|
.join(".")
|
|
}
|
|
|
|
fn decode_linked_schema_part(value: &str) -> Option<String> {
|
|
percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned())
|
|
}
|
|
|
|
pub fn qualified_table_name(database_type: Option<DatabaseType>, schema: Option<&str>, table_name: &str) -> String {
|
|
if database_type == Some(DatabaseType::Iotdb) {
|
|
let table_name = quote_table_identifier(database_type, table_name);
|
|
let schema = schema.map(str::trim).filter(|schema| !schema.is_empty());
|
|
if let Some(schema) = schema {
|
|
if table_name == schema || table_name.starts_with(&format!("{schema}.")) {
|
|
return table_name;
|
|
}
|
|
return format!("{}.{}", quote_table_identifier(database_type, schema), table_name);
|
|
}
|
|
return table_name;
|
|
}
|
|
let supports_qualifier = database_type.is_some_and(is_schema_aware)
|
|
|| matches!(
|
|
database_type,
|
|
Some(DatabaseType::Mysql | DatabaseType::Goldendb | DatabaseType::StarRocks | DatabaseType::Doris)
|
|
);
|
|
if supports_qualifier
|
|
&& database_type != Some(DatabaseType::Jdbc)
|
|
&& schema.is_some_and(|schema| !schema.trim().is_empty())
|
|
{
|
|
if database_type == Some(DatabaseType::SqlServer) {
|
|
if let Some(linked) = schema.and_then(parse_sqlserver_linked_schema_ref) {
|
|
return sqlserver_linked_table_name(&linked, table_name);
|
|
}
|
|
}
|
|
return format!(
|
|
"{}.{}",
|
|
quote_table_identifier(database_type, schema.unwrap()),
|
|
quote_table_identifier(database_type, table_name)
|
|
);
|
|
}
|
|
quote_table_identifier(database_type, table_name)
|
|
}
|
|
|
|
/// Like `qualified_table_name`, but also supports 3-part names for SQL Server
|
|
/// (`<database>.<schema>.<table>`) and Doris/StarRocks external catalogs
|
|
/// (`<catalog>.<database>.<table>`). SQL parsing stores the first segment of a
|
|
/// 3-part source in `catalog`; for SQL Server that segment is its database.
|
|
/// Doris/StarRocks use `schema` as the middle segment when present, otherwise
|
|
/// `database`, and ignore their built-in `internal` catalog.
|
|
pub fn qualified_table_name_with_catalog(
|
|
database_type: Option<DatabaseType>,
|
|
catalog: Option<&str>,
|
|
schema: Option<&str>,
|
|
database: Option<&str>,
|
|
table_name: &str,
|
|
) -> String {
|
|
let catalog = catalog.map(str::trim).filter(|catalog| !catalog.is_empty());
|
|
match (catalog, database_type) {
|
|
(Some(database), Some(DatabaseType::SqlServer)) => {
|
|
let table = qualified_table_name(database_type, schema, table_name);
|
|
format!("{}.{}", quote_table_identifier(database_type, database), table)
|
|
}
|
|
(Some(catalog), Some(DatabaseType::Doris | DatabaseType::StarRocks)) if catalog != "internal" => {
|
|
let middle = schema
|
|
.map(str::trim)
|
|
.filter(|schema| !schema.is_empty())
|
|
.or_else(|| database.map(str::trim).filter(|database| !database.is_empty()));
|
|
let table = match middle {
|
|
Some(middle) => format!(
|
|
"{}.{}",
|
|
quote_table_identifier(database_type, middle),
|
|
quote_table_identifier(database_type, table_name)
|
|
),
|
|
None => quote_table_identifier(database_type, table_name),
|
|
};
|
|
format!("{}.{}", quote_table_identifier(database_type, catalog), table)
|
|
}
|
|
_ => qualified_table_name(database_type, schema, table_name),
|
|
}
|
|
}
|
|
|
|
pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -> String {
|
|
if matches!(database_type, Some(DatabaseType::Gaussdb | DatabaseType::OpenGauss))
|
|
&& is_explicitly_quoted_identifier(name)
|
|
{
|
|
return name.to_string();
|
|
}
|
|
match database_type {
|
|
Some(DatabaseType::Iotdb) => name.to_string(),
|
|
// SOQL has no delimited identifiers: `SELECT * FROM "Account"` fails with
|
|
// MALFORMED_QUERY, and double quotes are string literals there. Object and
|
|
// field API names are always sent bare.
|
|
Some(DatabaseType::Salesforce) => name.to_string(),
|
|
// JDBC connections use the driver-reported identifier quote string
|
|
// (DatabaseMetaData.getIdentifierQuoteString()) inside the JDBC agent,
|
|
// so the Rust layer passes identifiers through unquoted.
|
|
Some(DatabaseType::Jdbc) => name.to_string(),
|
|
Some(
|
|
DatabaseType::Mysql
|
|
| DatabaseType::ClickHouse
|
|
| DatabaseType::Doris
|
|
| DatabaseType::Goldendb
|
|
| DatabaseType::StarRocks
|
|
| DatabaseType::ManticoreSearch
|
|
| DatabaseType::Hive
|
|
| DatabaseType::Kyuubi
|
|
| DatabaseType::Impala
|
|
| DatabaseType::Argo
|
|
| DatabaseType::Spark
|
|
| DatabaseType::Databricks
|
|
| DatabaseType::Databend
|
|
| DatabaseType::Tdengine
|
|
| DatabaseType::Access
|
|
| DatabaseType::Bigquery
|
|
// GoogleSQL (Spanner's default dialect) quotes identifiers with backticks;
|
|
// double quotes are string literals there, so `SELECT * FROM "users"` is a
|
|
// syntax error. PostgreSQL-dialect Spanner databases override this through
|
|
// the identifier quote reported by the agent on connect.
|
|
| DatabaseType::Spanner
|
|
| DatabaseType::Questdb,
|
|
) => {
|
|
format!("`{}`", name.replace('`', "``"))
|
|
}
|
|
Some(DatabaseType::Informix) if is_simple_informix_identifier(name) => name.to_string(),
|
|
Some(DatabaseType::Neo4j) => format!("`{}`", name.replace('`', "``")),
|
|
Some(DatabaseType::SqlServer) => format!("[{}]", name.replace(']', "]]")),
|
|
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
|
}
|
|
}
|
|
|
|
/// Quote an IRIS identifier only when its spelling requires a delimited name.
|
|
///
|
|
/// Caché/IRIS installations may disable delimited identifiers. The IRIS JDBC
|
|
/// preparser turns quoted names into `:%qpar(...)` parameters in that mode,
|
|
/// which is not valid in table references. Ordinary metadata names are
|
|
/// case-insensitive and can be sent unquoted; names containing separators or
|
|
/// other punctuation still need the identifier quote character.
|
|
pub fn quote_iris_identifier(name: &str, identifier_quote: Option<&str>) -> String {
|
|
let mut chars = name.chars();
|
|
let ordinary = chars.next().is_some_and(|first| first == '_' || first == '%' || first.is_alphabetic())
|
|
&& chars.all(|ch| ch == '_' || ch.is_alphanumeric());
|
|
if ordinary {
|
|
return name.to_string();
|
|
}
|
|
let quote = identifier_quote.map(str::trim).filter(|quote| !quote.is_empty()).unwrap_or("\"");
|
|
format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}")))
|
|
}
|
|
|
|
pub fn quote_gaussdb_jdbc_identifier(name: &str, identifier_quote: &str) -> String {
|
|
if is_explicitly_quoted_identifier(name) {
|
|
return name.to_string();
|
|
}
|
|
let quote = identifier_quote.trim();
|
|
if quote.is_empty() {
|
|
return name.to_string();
|
|
}
|
|
let requires_quote = !is_simple_lower_identifier(name)
|
|
|| is_postgres_reserved_identifier(name)
|
|
|| (quote == "`" && is_mysql_only_reserved_identifier(name));
|
|
if !requires_quote {
|
|
return name.to_string();
|
|
}
|
|
format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}")))
|
|
}
|
|
|
|
fn is_explicitly_quoted_identifier(name: &str) -> bool {
|
|
name.len() >= 2
|
|
&& ((name.starts_with('"') && name.ends_with('"'))
|
|
|| (name.starts_with('`') && name.ends_with('`'))
|
|
|| (name.starts_with('[') && name.ends_with(']')))
|
|
}
|
|
|
|
fn is_simple_lower_identifier(name: &str) -> bool {
|
|
let mut chars = name.chars();
|
|
let Some(first) = chars.next() else {
|
|
return false;
|
|
};
|
|
(first == '_' || first.is_ascii_lowercase())
|
|
&& chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_lowercase() || ch.is_ascii_digit())
|
|
}
|
|
|
|
fn is_postgres_reserved_identifier(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"all"
|
|
| "analyse"
|
|
| "analyze"
|
|
| "and"
|
|
| "any"
|
|
| "array"
|
|
| "as"
|
|
| "asc"
|
|
| "asymmetric"
|
|
| "authorization"
|
|
| "binary"
|
|
| "both"
|
|
| "case"
|
|
| "cast"
|
|
| "check"
|
|
| "collate"
|
|
| "collation"
|
|
| "column"
|
|
| "concurrently"
|
|
| "constraint"
|
|
| "create"
|
|
| "cross"
|
|
| "current_catalog"
|
|
| "current_date"
|
|
| "current_role"
|
|
| "current_schema"
|
|
| "current_time"
|
|
| "current_timestamp"
|
|
| "current_user"
|
|
| "default"
|
|
| "deferrable"
|
|
| "desc"
|
|
| "distinct"
|
|
| "do"
|
|
| "else"
|
|
| "end"
|
|
| "except"
|
|
| "false"
|
|
| "fetch"
|
|
| "for"
|
|
| "foreign"
|
|
| "freeze"
|
|
| "from"
|
|
| "full"
|
|
| "grant"
|
|
| "group"
|
|
| "having"
|
|
| "ilike"
|
|
| "in"
|
|
| "initially"
|
|
| "inner"
|
|
| "intersect"
|
|
| "into"
|
|
| "is"
|
|
| "isnull"
|
|
| "join"
|
|
| "lateral"
|
|
| "leading"
|
|
| "left"
|
|
| "like"
|
|
| "limit"
|
|
| "localtime"
|
|
| "localtimestamp"
|
|
| "natural"
|
|
| "not"
|
|
| "notnull"
|
|
| "null"
|
|
| "offset"
|
|
| "on"
|
|
| "only"
|
|
| "or"
|
|
| "order"
|
|
| "outer"
|
|
| "overlaps"
|
|
| "placing"
|
|
| "primary"
|
|
| "references"
|
|
| "returning"
|
|
| "right"
|
|
| "select"
|
|
| "session_user"
|
|
| "similar"
|
|
| "some"
|
|
| "symmetric"
|
|
| "system_user"
|
|
| "table"
|
|
| "tablesample"
|
|
| "then"
|
|
| "to"
|
|
| "trailing"
|
|
| "true"
|
|
| "union"
|
|
| "unique"
|
|
| "user"
|
|
| "using"
|
|
| "variadic"
|
|
| "verbose"
|
|
| "when"
|
|
| "where"
|
|
| "window"
|
|
| "with"
|
|
)
|
|
}
|
|
|
|
fn is_mysql_only_reserved_identifier(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"accessible"
|
|
| "auto_increment"
|
|
| "change"
|
|
| "database"
|
|
| "databases"
|
|
| "delayed"
|
|
| "describe"
|
|
| "div"
|
|
| "dual"
|
|
| "enclosed"
|
|
| "escaped"
|
|
| "explain"
|
|
| "force"
|
|
| "fulltext"
|
|
| "high_priority"
|
|
| "ignore"
|
|
| "index"
|
|
| "infile"
|
|
| "key"
|
|
| "keys"
|
|
| "kill"
|
|
| "linear"
|
|
| "lines"
|
|
| "load"
|
|
| "lock"
|
|
| "low_priority"
|
|
| "master_ssl_verify_server_cert"
|
|
| "maxvalue"
|
|
| "mediumint"
|
|
| "mod"
|
|
| "no_write_to_binlog"
|
|
| "optimize"
|
|
| "optionally"
|
|
| "outfile"
|
|
| "partition"
|
|
| "purge"
|
|
| "range"
|
|
| "read_write"
|
|
| "regexp"
|
|
| "release"
|
|
| "rename"
|
|
| "replace"
|
|
| "require"
|
|
| "rlike"
|
|
| "schema"
|
|
| "schemas"
|
|
| "separator"
|
|
| "show"
|
|
| "spatial"
|
|
| "sql_big_result"
|
|
| "sql_calc_found_rows"
|
|
| "sql_small_result"
|
|
| "ssl"
|
|
| "starting"
|
|
| "straight_join"
|
|
| "terminated"
|
|
| "tinyint"
|
|
| "unlock"
|
|
| "unsigned"
|
|
| "use"
|
|
| "utc_date"
|
|
| "utc_time"
|
|
| "utc_timestamp"
|
|
| "values"
|
|
| "varbinary"
|
|
| "varchar"
|
|
| "write"
|
|
| "xor"
|
|
| "zerofill"
|
|
)
|
|
}
|
|
|
|
pub fn normalize_where_input(where_input: Option<&str>) -> String {
|
|
let trimmed = where_input.unwrap_or("").trim().trim_end_matches(';').trim();
|
|
let mut chars = trimmed.chars();
|
|
let prefix = chars.by_ref().take(5).collect::<String>();
|
|
if prefix.eq_ignore_ascii_case("where") {
|
|
chars.as_str().trim().to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn quote_transfer_identifier(name: &str, database_type: &DatabaseType) -> String {
|
|
match database_type {
|
|
DatabaseType::Mysql
|
|
| DatabaseType::ClickHouse
|
|
| DatabaseType::Doris
|
|
| DatabaseType::StarRocks
|
|
| DatabaseType::Hive
|
|
| DatabaseType::Kyuubi
|
|
| DatabaseType::Impala
|
|
| DatabaseType::Argo
|
|
| DatabaseType::Spark
|
|
| DatabaseType::Questdb => format!("`{}`", name.replace('`', "``")),
|
|
DatabaseType::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
|
_ => format!("\"{}\"", name.replace('\"', "\"\"")),
|
|
}
|
|
}
|
|
|
|
pub fn transfer_column_identifier(name: &str, database_type: &DatabaseType, quote_target_column_names: bool) -> String {
|
|
if quote_target_column_names
|
|
|| !matches!(database_type, DatabaseType::Gaussdb | DatabaseType::OpenGauss)
|
|
|| !is_simple_unquoted_identifier(name)
|
|
|| is_postgres_reserved_identifier(&name.to_ascii_lowercase())
|
|
{
|
|
quote_transfer_identifier(name, database_type)
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
fn is_simple_unquoted_identifier(name: &str) -> bool {
|
|
let mut chars = name.chars();
|
|
let Some(first) = chars.next() else {
|
|
return false;
|
|
};
|
|
(first == '_' || first.is_ascii_alphabetic())
|
|
&& chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
|
|
}
|
|
|
|
/// Qualified table name for transfer SQL.
|
|
///
|
|
/// * Without catalog: produces `schema.table` (or just `table` for MySQL family).
|
|
/// * With catalog AND the database type supports external catalogs (Doris/StarRocks):
|
|
/// produces `catalog.schema.table` — the 3-part form those engines require to
|
|
/// address objects in an external (non-internal) catalog.
|
|
pub fn qualified_transfer_table(
|
|
table_name: &str,
|
|
schema: &str,
|
|
database_type: &DatabaseType,
|
|
catalog: Option<&str>,
|
|
) -> String {
|
|
let table = quote_transfer_identifier(table_name, database_type);
|
|
if let Some(catalog) = catalog {
|
|
format!(
|
|
"{}.{}.{}",
|
|
quote_transfer_identifier(catalog, database_type),
|
|
quote_transfer_identifier(schema, database_type),
|
|
table
|
|
)
|
|
} else if schema.is_empty()
|
|
|| matches!(database_type, DatabaseType::Mysql | DatabaseType::MongoDb | DatabaseType::Questdb)
|
|
{
|
|
table
|
|
} else {
|
|
format!("{}.{}", quote_transfer_identifier(schema, database_type), table)
|
|
}
|
|
}
|