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 { 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::>() .join(".") } fn decode_linked_schema_part(value: &str) -> Option { percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned()) } pub fn qualified_table_name(database_type: Option, 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 /// (`..`) and Doris/StarRocks external catalogs /// (`..
`). 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, 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, 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::(); 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) } }