464 lines
17 KiB
Rust
464 lines
17 KiB
Rust
use super::column_format::mysql_temporal_precision;
|
|
use super::dialect::StructureDialect;
|
|
use super::types::EditableStructureColumn;
|
|
use crate::models::connection::DatabaseType;
|
|
|
|
/// Dialects whose qualified names use `schema.table` when a non-empty schema is present.
|
|
fn is_schema_qualifying_dialect(dialect: StructureDialect) -> bool {
|
|
matches!(
|
|
dialect,
|
|
StructureDialect::Postgres
|
|
| StructureDialect::Oracle
|
|
| StructureDialect::Dameng
|
|
| StructureDialect::Oscar
|
|
| StructureDialect::SqlServer
|
|
| StructureDialect::H2
|
|
| StructureDialect::Informix
|
|
| StructureDialect::Sqlite
|
|
)
|
|
}
|
|
|
|
pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, table_name: &str) -> String {
|
|
if is_schema_qualifying_dialect(dialect) && schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
|
return format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, table_name));
|
|
}
|
|
quote_ident(dialect, table_name)
|
|
}
|
|
|
|
/// Qualify a table being created while preserving the schema as an existing object.
|
|
///
|
|
/// Oracle's ordinary identifiers are case-insensitive and are folded to uppercase. The
|
|
/// regular `qualified_table`/`quote_ident` pair must remain exact because it is also used
|
|
/// for tables already loaded from metadata, including quoted mixed-case names.
|
|
pub(super) fn qualified_new_table(
|
|
database_type: Option<DatabaseType>,
|
|
dialect: StructureDialect,
|
|
schema: Option<&str>,
|
|
table_name: &str,
|
|
) -> String {
|
|
if is_schema_qualifying_dialect(dialect) && schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
|
return format!(
|
|
"{}.{}",
|
|
quote_ident(dialect, schema.unwrap()),
|
|
quote_new_ident(database_type, dialect, table_name)
|
|
);
|
|
}
|
|
quote_new_ident(database_type, dialect, table_name)
|
|
}
|
|
|
|
pub(super) fn quote_ident(dialect: StructureDialect, name: &str) -> String {
|
|
match dialect {
|
|
StructureDialect::Mysql
|
|
| StructureDialect::Doris
|
|
| StructureDialect::GaussdbM
|
|
| StructureDialect::ManticoreSearch
|
|
| StructureDialect::Questdb => {
|
|
format!("`{}`", name.replace('`', "``"))
|
|
}
|
|
StructureDialect::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
|
StructureDialect::Informix if is_simple_informix_identifier(name) => name.to_string(),
|
|
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
|
}
|
|
}
|
|
|
|
fn is_simple_informix_identifier(name: &str) -> bool {
|
|
let mut chars = name.chars();
|
|
let Some(first) = chars.next() else {
|
|
return false;
|
|
};
|
|
(first.is_ascii_alphabetic() || first == '_')
|
|
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
|
|
}
|
|
|
|
/// Format an identifier that will be created by a CREATE/ADD statement.
|
|
///
|
|
/// This is deliberately separate from `quote_ident`: metadata-backed DDL must preserve the
|
|
/// exact spelling of an existing quoted Oracle object, while a newly entered ordinary name
|
|
/// should use Oracle's normal case-insensitive identifier semantics.
|
|
pub(super) fn quote_new_ident(database_type: Option<DatabaseType>, dialect: StructureDialect, name: &str) -> String {
|
|
if database_type == Some(DatabaseType::Oracle) && dialect == StructureDialect::Oracle {
|
|
oracle_new_object_reference(name)
|
|
} else {
|
|
quote_ident(dialect, name)
|
|
}
|
|
}
|
|
|
|
/// Reference spelling that resolves to an object created through [`quote_new_ident`]'s Oracle path.
|
|
///
|
|
/// Plain Oracle identifiers are created unquoted and are therefore stored uppercase-folded; a
|
|
/// later reference with the same unquoted spelling folds to the same object. Names that had to
|
|
/// stay quoted at creation (special characters, reserved words) keep exact quoting here too, so
|
|
/// generated references always line up with the created object.
|
|
pub fn oracle_new_object_reference(name: &str) -> String {
|
|
if is_simple_oracle_identifier(name) && !is_oracle_reserved_identifier(name) {
|
|
name.to_string()
|
|
} else {
|
|
quote_ident(StructureDialect::Oracle, name)
|
|
}
|
|
}
|
|
|
|
fn is_simple_oracle_identifier(name: &str) -> bool {
|
|
let mut chars = name.chars();
|
|
let Some(first) = chars.next() else {
|
|
return false;
|
|
};
|
|
first.is_ascii_alphabetic() && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || ch == '#')
|
|
}
|
|
|
|
// sqlparser maintains the shared SQL keyword vocabulary used by this crate. It intentionally
|
|
// includes more than Oracle's reserved words, so the conservative check is supplemented only
|
|
// with Oracle-specific legacy words missing from that maintained list instead of copying a
|
|
// separate several-hundred-entry keyword table here.
|
|
const ORACLE_RESERVED_WORDS_MISSING_FROM_SQLPARSER: &[&str] =
|
|
&["MAXEXTENTS", "NOAUDIT", "ROWID", "ROWNUM", "SUCCESSFUL", "UID"];
|
|
|
|
fn is_oracle_reserved_identifier(name: &str) -> bool {
|
|
sqlparser::keywords::ALL_KEYWORDS.iter().any(|keyword| keyword.eq_ignore_ascii_case(name))
|
|
|| ORACLE_RESERVED_WORDS_MISSING_FROM_SQLPARSER.iter().any(|keyword| keyword.eq_ignore_ascii_case(name))
|
|
}
|
|
|
|
pub(super) fn quote_string(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
|
|
fn is_sql_string_literal(value: &str) -> bool {
|
|
let trimmed = value.trim();
|
|
let Some(inner) = trimmed.strip_prefix('\'').and_then(|value| value.strip_suffix('\'')) else {
|
|
return false;
|
|
};
|
|
|
|
let mut chars = inner.chars().peekable();
|
|
while let Some(ch) = chars.next() {
|
|
if ch == '\'' && chars.next_if_eq(&'\'').is_none() {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn sqlserver_unicode_string_literal(value: &str) -> Option<String> {
|
|
let mut candidate = value.trim();
|
|
let mut parenthesis_depth = 0usize;
|
|
|
|
loop {
|
|
if let Some(literal) = candidate.strip_prefix('N').or_else(|| candidate.strip_prefix('n')) {
|
|
if is_sql_string_literal(literal) {
|
|
return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), literal, ")".repeat(parenthesis_depth)));
|
|
}
|
|
}
|
|
if is_sql_string_literal(candidate) {
|
|
return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), candidate, ")".repeat(parenthesis_depth)));
|
|
}
|
|
|
|
let inner = candidate.strip_prefix('(')?.strip_suffix(')')?;
|
|
parenthesis_depth += 1;
|
|
candidate = inner.trim();
|
|
}
|
|
}
|
|
|
|
fn postgres_string_default_literal(value: &str) -> Option<&str> {
|
|
let trimmed = value.trim();
|
|
let inner = trimmed.strip_prefix('\'')?;
|
|
let mut literal_end = 1;
|
|
let mut chars = inner.char_indices().peekable();
|
|
while let Some((index, ch)) = chars.next() {
|
|
if ch == '\'' {
|
|
if chars.next_if(|(_, next)| *next == '\'').is_some() {
|
|
continue;
|
|
}
|
|
literal_end += index + ch.len_utf8();
|
|
break;
|
|
}
|
|
}
|
|
if literal_end == 1 {
|
|
return None;
|
|
}
|
|
|
|
let literal = &trimmed[..literal_end];
|
|
if !is_sql_string_literal(literal) {
|
|
return None;
|
|
}
|
|
let cast_type = trimmed[literal_end..].trim().strip_prefix("::")?.trim();
|
|
if is_postgres_textual_cast_type(cast_type) {
|
|
Some(literal)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn is_postgres_textual_cast_type(value: &str) -> bool {
|
|
let normalized =
|
|
value.trim().trim_matches('"').split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
|
let base_type = normalized.split('(').next().unwrap_or(&normalized).trim();
|
|
matches!(
|
|
base_type,
|
|
"char"
|
|
| "character"
|
|
| "varchar"
|
|
| "character varying"
|
|
| "text"
|
|
| "bpchar"
|
|
| "name"
|
|
| "json"
|
|
| "jsonb"
|
|
| "xml"
|
|
| "bytea"
|
|
| "uuid"
|
|
)
|
|
}
|
|
|
|
pub fn clean(value: &str) -> String {
|
|
value.trim().to_string()
|
|
}
|
|
|
|
pub(super) fn is_protected_manticore_id_column(dialect: StructureDialect, column_name: &str) -> bool {
|
|
dialect == StructureDialect::ManticoreSearch && column_name.trim().eq_ignore_ascii_case("id")
|
|
}
|
|
|
|
pub(super) fn is_temporal_type_for_default(dialect: StructureDialect, base_type: &str) -> bool {
|
|
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
|
match dialect {
|
|
StructureDialect::Mysql | StructureDialect::Doris => {
|
|
matches!(normalized.as_str(), "date" | "datetime" | "timestamp" | "time" | "year")
|
|
}
|
|
StructureDialect::Postgres => {
|
|
matches!(
|
|
normalized.as_str(),
|
|
"date"
|
|
| "time"
|
|
| "time without time zone"
|
|
| "time with time zone"
|
|
| "timestamp"
|
|
| "timestamp without time zone"
|
|
| "timestamp with time zone"
|
|
| "interval"
|
|
| "timetz"
|
|
| "timestamptz"
|
|
) || normalized.starts_with("interval ")
|
|
}
|
|
StructureDialect::SqlServer => matches!(
|
|
normalized.as_str(),
|
|
"date" | "time" | "datetime" | "datetime2" | "smalldatetime" | "datetimeoffset"
|
|
),
|
|
StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => matches!(
|
|
normalized.as_str(),
|
|
"date"
|
|
| "timestamp"
|
|
| "timestamp with time zone"
|
|
| "timestamp with local time zone"
|
|
| "interval year to month"
|
|
| "interval day to second"
|
|
),
|
|
StructureDialect::H2 => {
|
|
matches!(
|
|
normalized.as_str(),
|
|
"date"
|
|
| "time"
|
|
| "time without time zone"
|
|
| "time with time zone"
|
|
| "timestamp"
|
|
| "timestamp without time zone"
|
|
| "timestamp with time zone"
|
|
) || normalized.starts_with("interval ")
|
|
}
|
|
StructureDialect::ClickHouse => {
|
|
matches!(normalized.as_str(), "date" | "date32" | "datetime" | "datetime64")
|
|
}
|
|
StructureDialect::Sqlite => {
|
|
matches!(normalized.as_str(), "date" | "datetime" | "timestamp" | "time")
|
|
}
|
|
StructureDialect::Informix => matches!(normalized.as_str(), "date" | "datetime"),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(super) fn is_temporal_expression(value: &str) -> bool {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return false;
|
|
}
|
|
if trimmed.contains('(') || trimmed.contains(')') {
|
|
return true;
|
|
}
|
|
trimmed.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
|
|
}
|
|
|
|
pub(super) fn is_string_type_for_default(dialect: StructureDialect, base_type: &str) -> bool {
|
|
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
|
match dialect {
|
|
StructureDialect::Mysql | StructureDialect::Doris => matches!(
|
|
normalized.as_str(),
|
|
"char"
|
|
| "varchar"
|
|
| "tinytext"
|
|
| "text"
|
|
| "mediumtext"
|
|
| "longtext"
|
|
| "binary"
|
|
| "varbinary"
|
|
| "tinyblob"
|
|
| "blob"
|
|
| "mediumblob"
|
|
| "longblob"
|
|
| "enum"
|
|
| "set"
|
|
| "json"
|
|
| "nvarchar"
|
|
| "nchar"
|
|
| "long"
|
|
),
|
|
StructureDialect::Postgres => matches!(
|
|
normalized.as_str(),
|
|
"char"
|
|
| "character"
|
|
| "varchar"
|
|
| "character varying"
|
|
| "text"
|
|
| "bpchar"
|
|
| "name"
|
|
| "json"
|
|
| "jsonb"
|
|
| "xml"
|
|
| "bytea"
|
|
| "uuid"
|
|
),
|
|
StructureDialect::SqlServer => matches!(
|
|
normalized.as_str(),
|
|
"char" | "varchar" | "nchar" | "nvarchar" | "text" | "ntext" | "xml" | "uniqueidentifier" | "sysname"
|
|
),
|
|
StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => matches!(
|
|
normalized.as_str(),
|
|
"char" | "nchar" | "varchar2" | "nvarchar2" | "clob" | "nclob" | "long" | "raw" | "long raw" | "bfile"
|
|
),
|
|
StructureDialect::H2 => matches!(
|
|
normalized.as_str(),
|
|
"char"
|
|
| "character"
|
|
| "varchar"
|
|
| "character varying"
|
|
| "text"
|
|
| "clob"
|
|
| "binary"
|
|
| "varbinary"
|
|
| "blob"
|
|
| "uuid"
|
|
| "json"
|
|
),
|
|
StructureDialect::ClickHouse => matches!(normalized.as_str(), "string" | "fixedstring" | "uuid" | "json"),
|
|
StructureDialect::Sqlite => {
|
|
matches!(normalized.as_str(), "text" | "varchar" | "char" | "character" | "clob" | "nvarchar" | "nchar")
|
|
}
|
|
StructureDialect::Informix => matches!(
|
|
normalized.as_str(),
|
|
"char"
|
|
| "character"
|
|
| "varchar"
|
|
| "character varying"
|
|
| "nvarchar"
|
|
| "nchar"
|
|
| "text"
|
|
| "clob"
|
|
| "lvarchar"
|
|
| "byte"
|
|
| "blob"
|
|
),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(super) fn format_default_for_sql(dialect: StructureDialect, data_type: &str, default_value: &str) -> String {
|
|
if default_value.is_empty() {
|
|
return String::new();
|
|
}
|
|
let base_type = data_type.split('(').next().unwrap_or(data_type).trim();
|
|
if is_temporal_type_for_default(dialect, base_type) {
|
|
if is_temporal_expression(default_value) {
|
|
return mysql_temporal_default_with_column_precision(dialect, data_type, default_value);
|
|
}
|
|
return quote_string(default_value);
|
|
}
|
|
if is_string_type_for_default(dialect, base_type) {
|
|
if dialect == StructureDialect::SqlServer
|
|
&& matches!(base_type.to_ascii_lowercase().as_str(), "nchar" | "nvarchar" | "ntext" | "sysname")
|
|
{
|
|
if let Some(literal) = sqlserver_unicode_string_literal(default_value) {
|
|
return literal;
|
|
}
|
|
if default_value.contains('(') || default_value.contains(')') {
|
|
return default_value.to_string();
|
|
}
|
|
return format!("N{}", quote_string(default_value));
|
|
}
|
|
// Only skip quoting for function-call expressions like `gen_random_uuid()`.
|
|
// Simple identifiers like `CURRENT_TIMESTAMP` are not valid defaults for string columns.
|
|
if is_sql_string_literal(default_value)
|
|
|| (dialect == StructureDialect::Postgres && postgres_string_default_literal(default_value).is_some())
|
|
|| default_value.contains('(')
|
|
|| default_value.contains(')')
|
|
{
|
|
return default_value.to_string();
|
|
}
|
|
return quote_string(default_value);
|
|
}
|
|
default_value.to_string()
|
|
}
|
|
|
|
/// MySQL requires a `CURRENT_TIMESTAMP` default on a fractional-precision
|
|
/// temporal column to spell out the column's precision (`datetime(3)` needs
|
|
/// `DEFAULT CURRENT_TIMESTAMP(3)`), otherwise the statement fails with
|
|
/// `ERROR 1067 (42000): Invalid default value`. Complete a bare
|
|
/// `CURRENT_TIMESTAMP` with the precision parsed from the data type; a value
|
|
/// that already carries parentheses is passed through unchanged.
|
|
fn mysql_temporal_default_with_column_precision(
|
|
dialect: StructureDialect,
|
|
data_type: &str,
|
|
default_value: &str,
|
|
) -> String {
|
|
let trimmed = default_value.trim();
|
|
if dialect != StructureDialect::Mysql || !trimmed.eq_ignore_ascii_case("current_timestamp") {
|
|
return default_value.to_string();
|
|
}
|
|
match mysql_temporal_precision(data_type) {
|
|
Some(fsp) => format!("{trimmed}({fsp})"),
|
|
None => default_value.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn normalize_default(value: Option<&String>) -> String {
|
|
let trimmed = value.map(|value| value.trim()).unwrap_or("");
|
|
if trimmed.eq_ignore_ascii_case("null") {
|
|
String::new()
|
|
} else if let Some(literal) = postgres_string_default_literal(trimmed) {
|
|
literal.to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn original_default(column: &EditableStructureColumn) -> String {
|
|
normalize_default(column.original.as_ref().and_then(|original| original.column_default.as_ref()))
|
|
}
|
|
|
|
pub fn original_comment(column: &EditableStructureColumn) -> String {
|
|
clean(column.original.as_ref().and_then(|original| original.comment.as_deref()).unwrap_or(""))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sqlserver_unicode_defaults_use_unicode_literals_without_double_quoting() {
|
|
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "中文"), "N'中文'");
|
|
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nchar(5)", "'中文'"), "N'中文'");
|
|
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "sysname", "n'guest'"), "N'guest'");
|
|
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "('中文')"), "(N'中文')");
|
|
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "((N'中文'))"), "((N'中文'))");
|
|
assert_eq!(
|
|
format_default_for_sql(StructureDialect::SqlServer, "nvarchar(max)", "CONCAT(N'a', N'b')"),
|
|
"CONCAT(N'a', N'b')"
|
|
);
|
|
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "varchar(50)", "中文"), "'中文'");
|
|
}
|
|
}
|