feat(core): split foundational capabilities into dedicated crates
This commit is contained in:
402 files changed
+4386
-3367
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,535 @@
|
||||
use super::dialect::{is_oracle_like, StructureDialect};
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::{clean, format_default_for_sql, normalize_default, quote_ident, quote_string};
|
||||
|
||||
/// Drops the `CHARACTER SET` / `COLLATE` inputs of MySQL columns that merely
|
||||
/// inherit the table's default collation, so the generated DDL does not spell
|
||||
/// out clauses the server would apply anyway.
|
||||
///
|
||||
/// MySQL reports the *effective* collation of every character column and never
|
||||
/// records whether it was written out explicitly, so "equals the table default"
|
||||
/// is the only signal available. Omitting the clauses is equivalent to keeping
|
||||
/// them: a column definition without `CHARACTER SET` takes the table default,
|
||||
/// which is exactly the value being dropped here. This runs on the DDL inputs
|
||||
/// only — introspection (`get_columns`) still reports the real values so the
|
||||
/// structure editor can show the column's current charset and collation.
|
||||
///
|
||||
/// The original snapshot is normalized alongside the draft so that an untouched
|
||||
/// column does not register as a charset change and trigger a needless `MODIFY`.
|
||||
pub(super) fn strip_inherited_mysql_column_charsets(options: &mut TableStructureSqlOptions) {
|
||||
let Some(table_collation) = options.table_collation.as_deref().map(str::trim).filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let inherits = |collation: &str| collation.trim().eq_ignore_ascii_case(table_collation);
|
||||
for column in &mut options.columns {
|
||||
if inherits(&column.collation) {
|
||||
column.character_set = String::new();
|
||||
column.collation = String::new();
|
||||
}
|
||||
if let Some(original) = column.original.as_mut() {
|
||||
if original.collation.as_deref().is_some_and(inherits) {
|
||||
original.character_set = None;
|
||||
original.collation = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStructureColumn) -> String {
|
||||
let data_type = column_data_type(dialect, column);
|
||||
let mut parts = vec![quote_ident(dialect, &column.name), data_type];
|
||||
// QuestDB SQL Syntax: ```ALTER TABLE tableName ADD COLUMN [IF NOT EXISTS] columnName typeDef```
|
||||
if dialect == StructureDialect::Questdb {
|
||||
parts.insert(0, "IF NOT EXISTS".to_string());
|
||||
return parts.join(" ");
|
||||
}
|
||||
// CHARACTER SET / COLLATE — MySQL character data types only
|
||||
if dialect == StructureDialect::Mysql && is_mysql_character_data_type(&column.data_type) {
|
||||
if !column.character_set.trim().is_empty() {
|
||||
parts.push(format!("CHARACTER SET {}", quote_ident(dialect, &column.character_set)));
|
||||
}
|
||||
if !column.collation.trim().is_empty() {
|
||||
parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation)));
|
||||
}
|
||||
}
|
||||
let mysql_generated_clause =
|
||||
(dialect == StructureDialect::Mysql).then(|| original_mysql_generated_clause(column)).flatten();
|
||||
if let Some(generated_clause) = mysql_generated_clause.as_ref() {
|
||||
parts.push(generated_clause.clone());
|
||||
}
|
||||
if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse {
|
||||
parts.push("NOT NULL".to_string());
|
||||
} else if column.is_nullable
|
||||
&& dialect == StructureDialect::Mysql
|
||||
&& mysql_generated_clause.is_none()
|
||||
&& is_mysql_timestamp_type(&column.data_type)
|
||||
{
|
||||
parts.push("NULL".to_string());
|
||||
}
|
||||
if mysql_generated_clause.is_none() {
|
||||
if let Some(extra_clause) = column_extra_clause(dialect, column) {
|
||||
parts.push(extra_clause);
|
||||
}
|
||||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if mysql_generated_clause.is_none() && !default_value.is_empty() {
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
}
|
||||
if mysql_generated_clause.is_none() {
|
||||
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
|
||||
if on_update && dialect == StructureDialect::Mysql {
|
||||
parts.push(mysql_on_update_current_timestamp_clause(&column.data_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM | StructureDialect::Doris)
|
||||
&& !clean(&column.comment).is_empty()
|
||||
{
|
||||
parts.push(format!("COMMENT {}", quote_string(&clean(&column.comment))));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn original_mysql_generated_clause(column: &EditableStructureColumn) -> Option<String> {
|
||||
let extra = column.original.as_ref()?.extra.as_deref()?.trim();
|
||||
let normalized = extra.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
(normalized.starts_with("generated ") && normalized.contains(" as (")).then(|| extra.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn original_is_mysql_generated_column(column: &EditableStructureColumn) -> bool {
|
||||
let Some(extra) = column.original.as_ref().and_then(|original| original.extra.as_deref()) else {
|
||||
return false;
|
||||
};
|
||||
let mut tokens = extra.split_whitespace();
|
||||
let first = tokens.next();
|
||||
first.is_some_and(|token| token.eq_ignore_ascii_case("generated"))
|
||||
|| matches!(first, Some(token) if token.eq_ignore_ascii_case("virtual")
|
||||
|| token.eq_ignore_ascii_case("stored")
|
||||
|| token.eq_ignore_ascii_case("persistent"))
|
||||
&& tokens.next().is_some_and(|token| token.eq_ignore_ascii_case("generated"))
|
||||
}
|
||||
|
||||
pub(super) fn column_extra_clause(dialect: StructureDialect, column: &EditableStructureColumn) -> Option<String> {
|
||||
let extra = column.extra.as_ref()?;
|
||||
match dialect {
|
||||
StructureDialect::Sqlite => {
|
||||
if extra.auto_increment.unwrap_or(false) && column.is_primary_key {
|
||||
Some("AUTOINCREMENT".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StructureDialect::Mysql => {
|
||||
let mut clauses = Vec::new();
|
||||
if extra.auto_increment.unwrap_or(false) {
|
||||
clauses.push("AUTO_INCREMENT".to_string());
|
||||
}
|
||||
if clauses.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(clauses.join(" "))
|
||||
}
|
||||
}
|
||||
StructureDialect::Postgres | StructureDialect::H2 => {
|
||||
if let Some(identity) = &extra.identity {
|
||||
let generation = identity.generation.as_deref().unwrap_or("BY DEFAULT");
|
||||
let mut clause = format!("GENERATED {generation} AS IDENTITY");
|
||||
if identity.seed.is_some() || identity.increment.is_some() {
|
||||
let start = identity.seed.unwrap_or(1);
|
||||
let inc = identity.increment.unwrap_or(1);
|
||||
clause.push_str(&format!(" (START WITH {start} INCREMENT BY {inc})"));
|
||||
}
|
||||
Some(clause)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StructureDialect::SqlServer => {
|
||||
if extra.auto_increment.unwrap_or(false) || extra.identity.is_some() {
|
||||
let seed = extra.identity.as_ref().and_then(|i| i.seed).unwrap_or(1);
|
||||
let increment = extra.identity.as_ref().and_then(|i| i.increment).unwrap_or(1);
|
||||
Some(format!("IDENTITY({seed}, {increment})"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StructureDialect::Dameng => {
|
||||
if extra.auto_increment.unwrap_or(false) || extra.identity.is_some() {
|
||||
let seed = extra.identity.as_ref().and_then(|i| i.seed).unwrap_or(1);
|
||||
let increment = extra.identity.as_ref().and_then(|i| i.increment).unwrap_or(1);
|
||||
Some(format!("IDENTITY({seed}, {increment})"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_dameng_identity(column: &EditableStructureColumn) -> bool {
|
||||
column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false) || extra.identity.is_some())
|
||||
}
|
||||
|
||||
pub(super) fn is_dameng_identity_compatible_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let (base_type, params) = match trimmed.find('(') {
|
||||
Some(open_index) => {
|
||||
let close_index = trimmed.rfind(')').unwrap_or(trimmed.len());
|
||||
(&trimmed[..open_index], trimmed.get(open_index + 1..close_index).unwrap_or(""))
|
||||
}
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
if matches!(normalized.as_str(), "tinyint" | "smallint" | "int" | "integer" | "bigint") {
|
||||
return true;
|
||||
}
|
||||
if !matches!(normalized.as_str(), "number" | "numeric" | "decimal" | "dec") {
|
||||
return false;
|
||||
}
|
||||
let normalized_params = params.split_whitespace().collect::<String>();
|
||||
if normalized_params.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let parts = normalized_params.split(',').collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[precision] => precision.parse::<u32>().is_ok(),
|
||||
[precision, scale] => precision.parse::<u32>().is_ok() && *scale == "0",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column_data_type(dialect: StructureDialect, column: &EditableStructureColumn) -> String {
|
||||
if dialect == StructureDialect::ClickHouse {
|
||||
return clickhouse_column_type(column);
|
||||
}
|
||||
if dialect == StructureDialect::ManticoreSearch {
|
||||
return manticore_column_type(column);
|
||||
}
|
||||
if dialect == StructureDialect::Questdb {
|
||||
return questdb_column_type(column);
|
||||
}
|
||||
let normalized = normalize_column_data_type(dialect, &column.data_type);
|
||||
// Dameng only recognizes its built-in type keywords in the canonical
|
||||
// upper-case form: a lower-case `varchar(50)` in DDL is stored as a
|
||||
// USER-DEFINED type instead of VARCHAR (issue #7343). Uppercase after the
|
||||
// dialect-specific normalization so its rewrites still apply.
|
||||
if dialect == StructureDialect::Dameng {
|
||||
return normalized.to_uppercase();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn manticore_column_type(column: &EditableStructureColumn) -> String {
|
||||
let data_type = normalize_column_data_type(StructureDialect::ManticoreSearch, &column.data_type);
|
||||
let normalized = data_type.trim().to_ascii_lowercase();
|
||||
if normalized == "json" {
|
||||
let Some(extra) = column.extra.as_ref() else {
|
||||
return data_type;
|
||||
};
|
||||
if extra.manticore_secondary_index.unwrap_or(false) {
|
||||
return format!("{data_type} secondary_index='1'");
|
||||
}
|
||||
return data_type;
|
||||
}
|
||||
if !matches!(normalized.as_str(), "text" | "string") {
|
||||
return data_type;
|
||||
}
|
||||
|
||||
let Some(extra) = column.extra.as_ref() else {
|
||||
return data_type;
|
||||
};
|
||||
let mut parts = vec![data_type];
|
||||
if extra.manticore_stored.unwrap_or(false) {
|
||||
parts.push("stored".to_string());
|
||||
}
|
||||
if extra.manticore_attribute.unwrap_or(false) {
|
||||
parts.push("attribute".to_string());
|
||||
}
|
||||
if extra.manticore_indexed.unwrap_or(false) {
|
||||
parts.push("indexed".to_string());
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn normalize_column_data_type(dialect: StructureDialect, data_type: &str) -> String {
|
||||
let trimmed = data_type.trim();
|
||||
let Some(open_index) = trimmed.find('(') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if !trimmed.ends_with(')') {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
let base_type = trimmed[..open_index].trim();
|
||||
let params = trimmed[open_index + 1..trimmed.len() - 1].trim();
|
||||
if base_type.is_empty() || params.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
if dialect == StructureDialect::Mysql {
|
||||
if let Some(normalized) = normalize_mysql_numeric_attribute_type(base_type, params) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if is_oracle_like(dialect) && is_oracle_lengthless_type(base_type) {
|
||||
// Dameng/Oracle integer aliases do not accept MySQL-style display widths like INTEGER(11).
|
||||
return base_type.to_string();
|
||||
}
|
||||
|
||||
if dialect == StructureDialect::SqlServer && is_sqlserver_lengthless_type(base_type) {
|
||||
// SQL Server exact integer and legacy LOB types do not accept MySQL-style display widths.
|
||||
return base_type.to_string();
|
||||
}
|
||||
|
||||
if dialect == StructureDialect::SqlServer && is_sqlserver_float_with_scale(base_type, params) {
|
||||
// SQL Server float only accepts a single integer mantissa bit count (1–53),
|
||||
// not comma-separated precision/scale like float(10,2).
|
||||
return base_type.to_string();
|
||||
}
|
||||
|
||||
if is_temporal_precision_type(dialect, base_type) {
|
||||
return if is_valid_temporal_precision(params, dialect) {
|
||||
format!("{base_type}({params})")
|
||||
} else {
|
||||
base_type.to_string()
|
||||
};
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn is_oracle_lengthless_type(base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"binary_double"
|
||||
| "binary_float"
|
||||
| "bigint"
|
||||
| "boolean"
|
||||
| "bool"
|
||||
| "byte"
|
||||
| "date"
|
||||
| "double"
|
||||
| "double precision"
|
||||
| "float"
|
||||
| "integer"
|
||||
| "int"
|
||||
| "long"
|
||||
| "long raw"
|
||||
| "nclob"
|
||||
| "real"
|
||||
| "smallint"
|
||||
| "text"
|
||||
| "tinyint"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_sqlserver_lengthless_type(base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"bigint"
|
||||
| "bit"
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "image"
|
||||
| "int"
|
||||
| "integer"
|
||||
| "money"
|
||||
| "ntext"
|
||||
| "real"
|
||||
| "smalldatetime"
|
||||
| "smallint"
|
||||
| "smallmoney"
|
||||
| "sql_variant"
|
||||
| "text"
|
||||
| "timestamp"
|
||||
| "tinyint"
|
||||
| "uniqueidentifier"
|
||||
| "xml"
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL Server `float` accepts a single integer mantissa bit count (1–53)
|
||||
/// but rejects comma-separated precision/scale like `float(10,2)`.
|
||||
fn is_sqlserver_float_with_scale(base_type: &str, params: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
normalized == "float" && params.contains(',')
|
||||
}
|
||||
|
||||
fn normalize_mysql_numeric_attribute_type(base_type: &str, params: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = base_type.split_whitespace().collect();
|
||||
let type_name = parts.first().copied()?.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
type_name.as_str(),
|
||||
"tinyint"
|
||||
| "smallint"
|
||||
| "mediumint"
|
||||
| "int"
|
||||
| "integer"
|
||||
| "bigint"
|
||||
| "real"
|
||||
| "double"
|
||||
| "float"
|
||||
| "decimal"
|
||||
| "numeric"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let split_index = parts.iter().position(|part| {
|
||||
let normalized = part.to_ascii_lowercase();
|
||||
matches!(normalized.as_str(), "signed" | "unsigned" | "zerofill")
|
||||
})?;
|
||||
if !parts[split_index..].iter().all(|part| {
|
||||
let normalized = part.to_ascii_lowercase();
|
||||
matches!(normalized.as_str(), "signed" | "unsigned" | "zerofill")
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let attrs = parts.split_off(split_index).join(" ");
|
||||
let base = parts.join(" ");
|
||||
Some(format!("{base}({params}) {attrs}"))
|
||||
}
|
||||
|
||||
pub(super) fn is_temporal_precision_type(dialect: StructureDialect, base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
match dialect {
|
||||
StructureDialect::Mysql => matches!(normalized.as_str(), "time" | "datetime" | "timestamp"),
|
||||
StructureDialect::Postgres => matches!(
|
||||
normalized.as_str(),
|
||||
"time"
|
||||
| "time without time zone"
|
||||
| "time with time zone"
|
||||
| "timestamp"
|
||||
| "timestamp without time zone"
|
||||
| "timestamp with time zone"
|
||||
),
|
||||
StructureDialect::SqlServer => matches!(normalized.as_str(), "time" | "datetime2" | "datetimeoffset"),
|
||||
StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => {
|
||||
matches!(normalized.as_str(), "timestamp" | "timestamp with time zone" | "timestamp with local time zone")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_valid_temporal_precision(params: &str, dialect: StructureDialect) -> bool {
|
||||
let Ok(value) = params.parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
let max = if matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar) {
|
||||
9
|
||||
} else {
|
||||
6
|
||||
};
|
||||
value <= max && params == value.to_string()
|
||||
}
|
||||
|
||||
/// Fractional-seconds precision of a MySQL temporal column type, e.g. `3` for
|
||||
/// `datetime(3)`. MySQL requires every `CURRENT_TIMESTAMP` in a column
|
||||
/// definition to carry the same precision the column type declares: a
|
||||
/// `datetime(3) ... DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`
|
||||
/// definition is rejected with `ERROR 1067 (42000): Invalid default value`
|
||||
/// while the `(3)`-suffixed clauses succeed. The editor model keeps only an
|
||||
/// on-update flag, so the precision has to be recovered from the data type.
|
||||
/// Non-temporal types and invalid precisions fall back to `None` (no suffix).
|
||||
pub(super) fn mysql_temporal_precision(data_type: &str) -> Option<String> {
|
||||
let trimmed = data_type.trim();
|
||||
let open_index = trimmed.find('(')?;
|
||||
if !trimmed.ends_with(')') {
|
||||
return None;
|
||||
}
|
||||
let base_type = trimmed[..open_index].trim();
|
||||
let params = trimmed[open_index + 1..trimmed.len() - 1].trim();
|
||||
if !is_temporal_precision_type(StructureDialect::Mysql, base_type)
|
||||
|| !is_valid_temporal_precision(params, StructureDialect::Mysql)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(params.to_string())
|
||||
}
|
||||
|
||||
/// `ON UPDATE CURRENT_TIMESTAMP` clause for a MySQL column, carrying the
|
||||
/// column's temporal precision (if any) so the clause matches the type.
|
||||
pub(super) fn mysql_on_update_current_timestamp_clause(data_type: &str) -> String {
|
||||
match mysql_temporal_precision(data_type) {
|
||||
Some(fsp) => format!("ON UPDATE CURRENT_TIMESTAMP({fsp})"),
|
||||
None => "ON UPDATE CURRENT_TIMESTAMP".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clickhouse_column_type(column: &EditableStructureColumn) -> String {
|
||||
let data_type = column.data_type.trim();
|
||||
if column.is_nullable {
|
||||
if data_type.to_ascii_lowercase().starts_with("nullable") {
|
||||
data_type.to_string()
|
||||
} else {
|
||||
format!("Nullable({data_type})")
|
||||
}
|
||||
} else {
|
||||
unwrap_clickhouse_nullable_type(data_type)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn unwrap_clickhouse_nullable_type(data_type: &str) -> String {
|
||||
let trimmed = data_type.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.starts_with("nullable(") && trimmed.ends_with(')') {
|
||||
trimmed[trimmed.find('(').unwrap_or(0) + 1..trimmed.len() - 1].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// QuestDB 类型处理
|
||||
/// geohash, decimal 这2种类型才能带()
|
||||
pub(super) fn questdb_column_type(column: &EditableStructureColumn) -> String {
|
||||
let data_type = column.data_type.trim().to_ascii_lowercase();
|
||||
let length_types: [&str; 2] = ["geohash", "decimal"];
|
||||
match data_type.find('(') {
|
||||
Some(pos) => {
|
||||
let base_type = &data_type[..pos];
|
||||
let params = &data_type[pos..];
|
||||
if length_types.contains(&base_type) {
|
||||
format!("{}{}", base_type, params)
|
||||
} else {
|
||||
base_type.to_string()
|
||||
}
|
||||
}
|
||||
None => data_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the data type is a MySQL character/string type that accepts
|
||||
/// `CHARACTER SET` and `COLLATE` clauses in column definitions.
|
||||
///
|
||||
/// Character types that support per-column charset/collation:
|
||||
/// `char`, `varchar`, `tinytext`, `text`, `mediumtext`, `longtext`, `enum`, `set`
|
||||
///
|
||||
/// Numeric, temporal, spatial, binary/blob, and JSON types do NOT support these clauses.
|
||||
pub(super) fn is_mysql_character_data_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let base_type = match trimmed.find('(') {
|
||||
Some(open_index) => trimmed[..open_index].trim(),
|
||||
None => trimmed,
|
||||
};
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
matches!(normalized.as_str(), "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext" | "enum" | "set")
|
||||
}
|
||||
|
||||
/// MySQL silently rewrites a nullable `TIMESTAMP` column to `NOT NULL` when the
|
||||
/// generated DDL omits an explicit `NULL` keyword — regardless of whether a
|
||||
/// `DEFAULT` is present. With the (still common) `explicit_defaults_for_timestamp`
|
||||
/// server default of `OFF`, this can outright fail with `ERROR 1067 (42000):
|
||||
/// Invalid default value` for any non-first TIMESTAMP column. `DATETIME` is not
|
||||
/// affected and must not be touched.
|
||||
pub(super) fn is_mysql_timestamp_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let base_type = match trimmed.find('(') {
|
||||
Some(open_index) => trimmed[..open_index].trim(),
|
||||
None => trimmed,
|
||||
};
|
||||
base_type.eq_ignore_ascii_case("timestamp")
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
use super::column_alter::{
|
||||
build_clickhouse_existing_column_sql, build_dameng_existing_column_sql, build_doris_existing_column_sql,
|
||||
build_h2_existing_column_sql, build_informix_existing_column_sql, build_iris_existing_column_sql,
|
||||
build_mysql_existing_column_clause, build_oracle_like_existing_column_sql, build_oscar_existing_column_sql,
|
||||
build_postgres_existing_column_sql, build_questdb_existing_column_sql, build_sqlite_existing_column_sql,
|
||||
build_sqlserver_existing_column_sql, build_xugu_existing_column_sql, dameng_drops_identity,
|
||||
has_column_extra_change, has_existing_column_attribute_change, validate_dameng_existing_identity_change,
|
||||
};
|
||||
use super::column_format::{
|
||||
column_definition, has_dameng_identity, is_dameng_identity_compatible_type, is_mysql_character_data_type,
|
||||
original_is_mysql_generated_column, original_mysql_generated_clause,
|
||||
};
|
||||
use super::comments::build_sqlserver_column_comment_sql_for_profile;
|
||||
use super::dialect::{capabilities_for, database_label, is_oracle_like, StructureDialect};
|
||||
use super::indexes::has_existing_index_change;
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::{
|
||||
clean, is_protected_manticore_id_column, normalize_default, original_comment, original_default, qualified_table,
|
||||
quote_ident, quote_string,
|
||||
};
|
||||
use crate::models::connection::DatabaseType;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let dialect = capabilities.dialect;
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let database_label = database_label(options.database_type);
|
||||
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
||||
if dialect == StructureDialect::Dameng {
|
||||
let identity_columns: Vec<_> = active_columns.iter().filter(|column| has_dameng_identity(column)).collect();
|
||||
if identity_columns.len() > 1
|
||||
|| identity_columns.iter().any(|column| {
|
||||
column.extra.as_ref().and_then(|extra| extra.identity.as_ref()).and_then(|identity| identity.increment)
|
||||
== Some(0)
|
||||
})
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut valid_identity_changes = true;
|
||||
for column in &active_columns {
|
||||
if has_dameng_identity(column) && !is_dameng_identity_compatible_type(&column.data_type) {
|
||||
warnings.push(format!(
|
||||
"Dameng identity column \"{}\" must use tinyint, smallint, int, integer, bigint, number, numeric, or decimal/dec with scale 0.",
|
||||
column.name
|
||||
));
|
||||
valid_identity_changes = false;
|
||||
} else if column.original.is_some() && !validate_dameng_existing_identity_change(column, warnings) {
|
||||
valid_identity_changes = false;
|
||||
}
|
||||
}
|
||||
if !valid_identity_changes {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
if is_oracle_like(dialect)
|
||||
&& active_columns.is_empty()
|
||||
&& options.columns.iter().any(|column| column.marked_for_drop)
|
||||
{
|
||||
warnings.push("Oracle does not allow dropping all columns from a table. Keep at least one column or drop the table instead.".to_string());
|
||||
return Vec::new();
|
||||
}
|
||||
let has_original_column_positions = active_columns.iter().any(|column| column.original_position.is_some());
|
||||
let mut simulated_column_order =
|
||||
if has_original_column_positions { original_active_column_order(&active_columns) } else { Vec::new() };
|
||||
// Pre-compute the minimal set of existing columns that really need an explicit move.
|
||||
// For MySQL/ClickHouse we keep the largest already-ordered subset in place and only
|
||||
// emit FIRST/AFTER SQL for columns outside that subset.
|
||||
let reordered_existing_column_ids =
|
||||
if has_original_column_positions && matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse) {
|
||||
planned_existing_column_move_ids(&active_columns)
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
let mysql_primary_key_change = if options.database_type == Some(DatabaseType::Mysql) && !options.is_gaussdb_m_mode {
|
||||
primary_key_change(options)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// MySQL validates AUTO_INCREMENT against the statement's final index layout. Keep the
|
||||
// dependent column and key clauses together so no implicit commit exposes an invalid middle state.
|
||||
let mysql_coalesced_primary_key_change = mysql_primary_key_change
|
||||
.as_ref()
|
||||
.filter(|change| options.columns.iter().any(|column| mysql_auto_increment_touches_primary_key(column, change)));
|
||||
let mut mysql_primary_key_column_clauses = Vec::new();
|
||||
let mut statements = Vec::new();
|
||||
// DM8 owns identity at table level, so remove the old identity before any per-column ADD,
|
||||
// even when the target column appears earlier in the submitted draft.
|
||||
if dialect == StructureDialect::Dameng
|
||||
&& options.columns.iter().any(|column| !column.marked_for_drop && dameng_drops_identity(column))
|
||||
{
|
||||
statements.push(format!("ALTER TABLE {table} DROP IDENTITY;"));
|
||||
}
|
||||
|
||||
for column in &options.columns {
|
||||
if column.marked_for_drop {
|
||||
let Some(original) = &column.original else {
|
||||
continue;
|
||||
};
|
||||
if !capabilities.drop_column {
|
||||
warnings.push(format!("Dropping columns is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if original.is_primary_key {
|
||||
warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name));
|
||||
continue;
|
||||
}
|
||||
if is_protected_manticore_id_column(dialect, &original.name) {
|
||||
warnings.push("Manticore Search id column cannot be dropped from this editor.".to_string());
|
||||
continue;
|
||||
}
|
||||
statements.push(build_drop_column_sql(dialect, &table, &original.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_index = active_columns.iter().position(|active| active.id == column.id).unwrap_or(0);
|
||||
let position_clause = if has_original_column_positions {
|
||||
column_position_clause(dialect, &active_columns, active_index)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let desired_previous_column_id = active_previous_column_id(&active_columns, active_index);
|
||||
// A position change only matters when this column is part of the planned move set
|
||||
// and its predecessor still differs in the simulated order.
|
||||
let has_position_change = has_original_column_positions
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse)
|
||||
&& reordered_existing_column_ids.contains(&column.id)
|
||||
&& column.original.is_some()
|
||||
&& column.original_position.is_some()
|
||||
&& simulated_column_position_changed(&simulated_column_order, &column.id, desired_previous_column_id);
|
||||
|
||||
if column.original.is_none() {
|
||||
if !capabilities.add_column {
|
||||
warnings.push(format!("Adding columns is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& has_sqlserver_identity(column)
|
||||
&& !is_sqlserver_identity_compatible_type(&column.data_type)
|
||||
{
|
||||
warnings.push(format!(
|
||||
"SQL Server identity column \"{}\" must use tinyint, smallint, int, bigint, or decimal/numeric with scale 0.",
|
||||
column.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !capabilities.comment && !clean(&column.comment).is_empty() {
|
||||
warnings.push(format!(
|
||||
"Column comments are not supported for {database_label} from this editor; the comment for \"{}\" was ignored.",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
if mysql_coalesced_primary_key_change
|
||||
.is_some_and(|change| mysql_auto_increment_touches_primary_key(column, change))
|
||||
{
|
||||
mysql_primary_key_column_clauses.push(format!(
|
||||
"ADD COLUMN {}{position_clause}",
|
||||
column_definition(StructureDialect::Mysql, column)
|
||||
));
|
||||
} else {
|
||||
statements.extend(build_add_column_sql(
|
||||
dialect,
|
||||
options.database_type,
|
||||
capabilities.comment,
|
||||
&table,
|
||||
column,
|
||||
&position_clause,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
if has_original_column_positions
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse)
|
||||
{
|
||||
apply_simulated_column_position(&mut simulated_column_order, &column.id, desired_previous_column_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A column handing its primary key membership over to another column keeps its
|
||||
// AUTO_INCREMENT flag in the submitted draft, which MySQL rejects (ERROR 1075) once
|
||||
// the column is no longer keyed. Rewrite it inside the same coalesced ALTER even
|
||||
// though the draft reports no change on the column itself.
|
||||
let clears_orphaned_auto_increment = mysql_coalesced_primary_key_change
|
||||
.is_some_and(|change| mysql_orphans_auto_increment_column(options, column, change));
|
||||
|
||||
if !has_existing_column_attribute_change(column)
|
||||
&& !has_column_extra_change(column)
|
||||
&& !has_position_change
|
||||
&& !clears_orphaned_auto_increment
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let original = column.original.as_ref().unwrap();
|
||||
let has_rename = column.name != original.name;
|
||||
let has_comment_change = clean(&column.comment) != original_comment(column);
|
||||
let has_attribute_change = column.data_type.trim() != original.data_type.trim()
|
||||
|| column.is_nullable != original.is_nullable
|
||||
|| normalize_default(Some(&column.default_value)) != original_default(column)
|
||||
|| (has_comment_change && capabilities.comment)
|
||||
|| (is_mysql_character_data_type(&column.data_type)
|
||||
&& (column.character_set.trim() != original.character_set.as_deref().unwrap_or("")
|
||||
|| column.collation.trim() != original.collation.as_deref().unwrap_or("")))
|
||||
|| has_column_extra_change(column);
|
||||
if has_comment_change && !capabilities.comment {
|
||||
warnings.push(format!(
|
||||
"Column comments are not supported for {database_label} from this editor; the comment change for \"{}\" was ignored.",
|
||||
original.name
|
||||
));
|
||||
}
|
||||
if has_position_change && !capabilities.reorder_column {
|
||||
warnings.push(format!("Reordering columns is not supported for {database_label} from this editor."));
|
||||
}
|
||||
if has_rename && !capabilities.rename_column {
|
||||
warnings.push(format!("Renaming columns is not supported for {database_label} from this editor."));
|
||||
}
|
||||
if has_attribute_change && !capabilities.alter_existing_column && dialect != StructureDialect::Sqlite {
|
||||
warnings.push(format!("Editing existing columns is not supported for {database_label} yet."));
|
||||
}
|
||||
if (has_position_change && !capabilities.reorder_column)
|
||||
|| (has_rename && !capabilities.rename_column)
|
||||
|| (has_attribute_change && !capabilities.alter_existing_column && dialect != StructureDialect::Sqlite)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if dialect == StructureDialect::Mysql
|
||||
&& original_is_mysql_generated_column(column)
|
||||
&& original_mysql_generated_clause(column).is_none()
|
||||
{
|
||||
warnings.push(format!(
|
||||
"Column \"{}\" is generated, but its generation expression could not be loaded; no ALTER statement was generated to avoid removing the generated-column definition.",
|
||||
original.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !has_rename && !has_attribute_change && !has_position_change && !clears_orphaned_auto_increment {
|
||||
continue;
|
||||
}
|
||||
|
||||
match dialect {
|
||||
StructureDialect::Mysql => {
|
||||
let rewritten;
|
||||
let effective_column = if clears_orphaned_auto_increment {
|
||||
rewritten = without_auto_increment(column);
|
||||
&rewritten
|
||||
} else {
|
||||
column
|
||||
};
|
||||
let clause = build_mysql_existing_column_clause(
|
||||
effective_column,
|
||||
if has_position_change { &position_clause } else { "" },
|
||||
);
|
||||
if mysql_coalesced_primary_key_change
|
||||
.is_some_and(|change| mysql_auto_increment_touches_primary_key(column, change))
|
||||
{
|
||||
mysql_primary_key_column_clauses.push(clause);
|
||||
} else {
|
||||
statements.push(format!("ALTER TABLE {table} {clause};"));
|
||||
}
|
||||
}
|
||||
StructureDialect::Doris => statements.extend(build_doris_existing_column_sql(&table, column, "")),
|
||||
StructureDialect::Postgres => statements.extend(build_postgres_existing_column_sql(&table, column)),
|
||||
StructureDialect::Oracle => {
|
||||
if options.database_type == Some(crate::models::connection::DatabaseType::Iris) {
|
||||
statements.extend(build_iris_existing_column_sql(&table, column));
|
||||
} else if options.database_type == Some(crate::models::connection::DatabaseType::Xugu) {
|
||||
statements.extend(build_xugu_existing_column_sql(&table, column));
|
||||
} else {
|
||||
statements.extend(build_oracle_like_existing_column_sql(dialect, &table, column))
|
||||
}
|
||||
}
|
||||
StructureDialect::Dameng => {
|
||||
statements.extend(build_dameng_existing_column_sql(&table, column, false, warnings))
|
||||
}
|
||||
// 神通 MODIFY 语法与 Oracle 有差异(NULL/NOT NULL 须单独一条),用专属实现。
|
||||
StructureDialect::Oscar => statements.extend(build_oscar_existing_column_sql(dialect, &table, column)),
|
||||
StructureDialect::H2 => statements.extend(build_h2_existing_column_sql(&table, column)),
|
||||
StructureDialect::ClickHouse => statements.extend(build_clickhouse_existing_column_sql(
|
||||
&table,
|
||||
column,
|
||||
if has_position_change { &position_clause } else { "" },
|
||||
)),
|
||||
StructureDialect::Informix => statements.extend(build_informix_existing_column_sql(&table, column)),
|
||||
StructureDialect::SqlServer => statements.extend(build_sqlserver_existing_column_sql(
|
||||
&table,
|
||||
column,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.driver_profile.as_deref(),
|
||||
warnings,
|
||||
)),
|
||||
StructureDialect::Sqlite => statements.extend(build_sqlite_existing_column_sql(&table, column, warnings)),
|
||||
StructureDialect::Questdb => statements.extend(build_questdb_existing_column_sql(&table, column)),
|
||||
_ => warnings.push(format!("Editing existing columns is not supported for {database_label} yet.")),
|
||||
}
|
||||
if has_position_change {
|
||||
apply_simulated_column_position(&mut simulated_column_order, &column.id, desired_previous_column_id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(change) = mysql_coalesced_primary_key_change {
|
||||
let mut clauses = Vec::new();
|
||||
if !change.old_ids.is_empty() {
|
||||
clauses.push("DROP PRIMARY KEY".to_string());
|
||||
}
|
||||
clauses.append(&mut mysql_primary_key_column_clauses);
|
||||
if !change.new_names.is_empty() {
|
||||
let pk_list = change
|
||||
.new_names
|
||||
.iter()
|
||||
.map(|name| quote_ident(StructureDialect::Mysql, name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
clauses.push(format!("ADD PRIMARY KEY ({pk_list})"));
|
||||
}
|
||||
statements.push(format!("ALTER TABLE {table} {};", clauses.join(", ")));
|
||||
} else {
|
||||
// Keep the existing key while column DDL validates. This avoids leaving a table
|
||||
// without a key when an incoming key column cannot be made valid.
|
||||
statements.extend(build_primary_key_sql(options, dialect, &table, warnings));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn was_primary_key_column(column: &EditableStructureColumn) -> bool {
|
||||
column.original.as_ref().is_some_and(|original| original.is_primary_key)
|
||||
}
|
||||
|
||||
/// Columns that should appear in `ADD PRIMARY KEY (...)` (must remain on the table).
|
||||
fn appears_in_add_primary_key(column: &EditableStructureColumn) -> bool {
|
||||
column.is_primary_key && !column.marked_for_drop
|
||||
}
|
||||
|
||||
struct PrimaryKeyChange<'a> {
|
||||
old_ids: HashSet<&'a str>,
|
||||
new_ids: HashSet<&'a str>,
|
||||
new_names: Vec<&'a str>,
|
||||
}
|
||||
|
||||
fn primary_key_change(options: &TableStructureSqlOptions) -> Option<PrimaryKeyChange<'_>> {
|
||||
if options.columns.iter().any(|column| column.marked_for_drop && was_primary_key_column(column)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let old_ids: HashSet<&str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| was_primary_key_column(column))
|
||||
.map(|column| column.id.as_str())
|
||||
.collect();
|
||||
let new_ids: HashSet<&str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| appears_in_add_primary_key(column))
|
||||
.map(|column| column.id.as_str())
|
||||
.collect();
|
||||
if old_ids == new_ids {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_names = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| appears_in_add_primary_key(column))
|
||||
.map(|column| column.name.as_str())
|
||||
.collect();
|
||||
Some(PrimaryKeyChange { old_ids, new_ids, new_names })
|
||||
}
|
||||
|
||||
fn unsupported_primary_key_change_warning(options: &TableStructureSqlOptions, change: &PrimaryKeyChange<'_>) -> String {
|
||||
let action = if change.old_ids.is_empty() { "Adding" } else { "Changing" };
|
||||
format!("{action} primary keys is not supported for {} from this editor.", database_label(options.database_type))
|
||||
}
|
||||
|
||||
pub(super) fn validate_primary_key_change_scope(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
if let Some(original) = options
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| column.marked_for_drop && was_primary_key_column(column))
|
||||
.and_then(|column| column.original.as_ref())
|
||||
{
|
||||
return vec![format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name)];
|
||||
}
|
||||
|
||||
let Some(change) = primary_key_change(options) else { return Vec::new() };
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let supported =
|
||||
if change.old_ids.is_empty() { capabilities.add_primary_key } else { capabilities.alter_primary_key };
|
||||
if supported {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![unsupported_primary_key_change_warning(options, &change)]
|
||||
}
|
||||
}
|
||||
|
||||
fn mysql_auto_increment_touches_primary_key(column: &EditableStructureColumn, change: &PrimaryKeyChange<'_>) -> bool {
|
||||
column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false))
|
||||
&& (change.old_ids.contains(column.id.as_str()) || change.new_ids.contains(column.id.as_str()))
|
||||
}
|
||||
|
||||
/// MySQL allows AUTO_INCREMENT only on a column that leads some key, so a column handing the
|
||||
/// primary key over to another column must lose the flag in the same statement — unless an
|
||||
/// already existing index that survives this change still keys it.
|
||||
fn mysql_orphans_auto_increment_column(
|
||||
options: &TableStructureSqlOptions,
|
||||
column: &EditableStructureColumn,
|
||||
change: &PrimaryKeyChange<'_>,
|
||||
) -> bool {
|
||||
column.original.is_some()
|
||||
&& column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false))
|
||||
&& change.old_ids.contains(column.id.as_str())
|
||||
&& !change.new_ids.contains(column.id.as_str())
|
||||
&& !mysql_column_leads_kept_index(options, column)
|
||||
}
|
||||
|
||||
/// Whether a persisted, non-primary index keys this column for the whole change.
|
||||
///
|
||||
/// Only an index the draft leaves completely untouched counts. One created by this draft is
|
||||
/// added after the column DDL, and an edited one is rebuilt as DROP + CREATE after it, so
|
||||
/// neither covers the column while the coalesced ALTER runs — and the DROP would hit the very
|
||||
/// same ERROR 1075. Matching is against the persisted names on both sides, so a draft that
|
||||
/// swaps two column names cannot credit one column's index to the other.
|
||||
///
|
||||
/// This applies the InnoDB rule that the auto column must *lead* an index. MyISAM also accepts
|
||||
/// it in a later position of a multi-column index; DBX has no engine information here, so such
|
||||
/// a table loses AUTO_INCREMENT rather than every InnoDB table keeping an invalid one.
|
||||
fn mysql_column_leads_kept_index(options: &TableStructureSqlOptions, column: &EditableStructureColumn) -> bool {
|
||||
let Some(original_name) = column.original.as_ref().map(|original| original.name.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
options.indexes.iter().any(|index| {
|
||||
!index.marked_for_drop
|
||||
&& !has_existing_index_change(index)
|
||||
&& index.original.as_ref().is_some_and(|original| {
|
||||
!original.is_primary
|
||||
&& original.columns.first().is_some_and(|first| clean(first).eq_ignore_ascii_case(original_name))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn without_auto_increment(column: &EditableStructureColumn) -> EditableStructureColumn {
|
||||
let mut cleared = column.clone();
|
||||
if let Some(extra) = cleared.extra.as_mut() {
|
||||
extra.auto_increment = Some(false);
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
pub(super) fn build_primary_key_sql(
|
||||
options: &TableStructureSqlOptions,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
|
||||
// Membership by draft id (set equality): pure rename / local reorder of the same key
|
||||
// columns is not a PK change. A draft that drops a PK column is also rejected here.
|
||||
let Some(change) = primary_key_change(options) else { return Vec::new() };
|
||||
|
||||
let supported =
|
||||
if change.old_ids.is_empty() { capabilities.add_primary_key } else { capabilities.alter_primary_key };
|
||||
if !supported {
|
||||
warnings.push(unsupported_primary_key_change_warning(options, &change));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let persisted_postgres_primary_key_name = if options.database_type == Some(DatabaseType::Postgres)
|
||||
&& !change.old_ids.is_empty()
|
||||
{
|
||||
let mut primary_indexes =
|
||||
options.indexes.iter().filter_map(|index| index.original.as_ref().filter(|original| original.is_primary));
|
||||
match (primary_indexes.next(), primary_indexes.next()) {
|
||||
(Some(primary_index), None) if !primary_index.name.is_empty() => Some(primary_index.name.as_str()),
|
||||
_ => {
|
||||
warnings.push(
|
||||
"Could not determine the existing PostgreSQL primary key constraint name. Refresh the table structure and try again."
|
||||
.to_string(),
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut statements = Vec::new();
|
||||
if !change.old_ids.is_empty() {
|
||||
let Some(drop_sql) = drop_primary_key_statement(dialect, table, options, persisted_postgres_primary_key_name)
|
||||
else {
|
||||
warnings.push(format!(
|
||||
"Changing primary keys is not supported for {} from this editor.",
|
||||
database_label(options.database_type)
|
||||
));
|
||||
return Vec::new();
|
||||
};
|
||||
statements.push(drop_sql);
|
||||
}
|
||||
|
||||
if !change.new_names.is_empty() {
|
||||
let pk_list = change.new_names.iter().map(|name| quote_ident(dialect, name)).collect::<Vec<_>>().join(", ");
|
||||
// DM8: ADD [CONSTRAINT name] PRIMARY KEY; anonymous form matches Navicat/DBeaver/MySQL editors.
|
||||
let constraint = persisted_postgres_primary_key_name
|
||||
.map(|name| format!("CONSTRAINT {} ", quote_ident(dialect, name)))
|
||||
.unwrap_or_default();
|
||||
statements.push(format!("ALTER TABLE {table} ADD {constraint}PRIMARY KEY ({pk_list});"));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
/// Dialect-specific DROP for an existing primary key.
|
||||
///
|
||||
/// - MySQL: `DROP PRIMARY KEY`
|
||||
/// - Dameng (DM8): official `DROP PRIMARY KEY [RESTRICT|CASCADE]`; default RESTRICT
|
||||
/// (no CASCADE — dependent FKs should not be silently removed).
|
||||
/// System names (`CONS…`) are not stable; name-based DROP is avoided.
|
||||
/// Cluster primary keys cannot use this path (DM8 restriction) — left to the server.
|
||||
/// - Postgres: `DROP CONSTRAINT <persisted primary index name>` for PostgreSQL;
|
||||
/// other Postgres-compatible engines retain the existing default-name behavior.
|
||||
fn drop_primary_key_statement(
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
options: &TableStructureSqlOptions,
|
||||
persisted_postgres_primary_key_name: Option<&str>,
|
||||
) -> Option<String> {
|
||||
match dialect {
|
||||
StructureDialect::Postgres => {
|
||||
let fallback_name;
|
||||
let pk_name = if let Some(name) = persisted_postgres_primary_key_name {
|
||||
name
|
||||
} else {
|
||||
let raw_table = options.table_name.split('.').next_back().unwrap_or(&options.table_name);
|
||||
fallback_name = format!("{}_pkey", clean(raw_table));
|
||||
&fallback_name
|
||||
};
|
||||
Some(format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, pk_name)))
|
||||
}
|
||||
// 神通 Oscar 实测支持 `ALTER TABLE ... DROP PRIMARY KEY`(与 Dameng/MySQL 一致)。
|
||||
StructureDialect::Mysql | StructureDialect::Dameng | StructureDialect::Oscar => {
|
||||
Some(format!("ALTER TABLE {table} DROP PRIMARY KEY;"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_sqlserver_identity(column: &EditableStructureColumn) -> bool {
|
||||
column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false) || extra.identity.is_some())
|
||||
}
|
||||
|
||||
fn is_sqlserver_identity_compatible_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let (base_type, params) = match trimmed.find('(') {
|
||||
Some(open_index) => {
|
||||
let close_index = trimmed.rfind(')').unwrap_or(trimmed.len());
|
||||
(&trimmed[..open_index], trimmed.get(open_index + 1..close_index).unwrap_or(""))
|
||||
}
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
if matches!(normalized.as_str(), "tinyint" | "smallint" | "int" | "integer" | "bigint") {
|
||||
return true;
|
||||
}
|
||||
if !matches!(normalized.as_str(), "decimal" | "numeric") {
|
||||
return false;
|
||||
}
|
||||
let normalized_params = params.split_whitespace().collect::<String>();
|
||||
if normalized_params.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let parts = normalized_params.split(',').collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[precision] => precision.parse::<u32>().is_ok(),
|
||||
[precision, scale] => precision.parse::<u32>().is_ok() && *scale == "0",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_add_column_sql(
|
||||
dialect: StructureDialect,
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
supports_comments: bool,
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
position_clause: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let definition = column_definition(dialect, column);
|
||||
let mut statements = if is_oracle_like(dialect) || dialect == StructureDialect::Informix {
|
||||
vec![format!("ALTER TABLE {table} ADD ({definition});")]
|
||||
} else {
|
||||
let add_keyword = if dialect == StructureDialect::SqlServer
|
||||
|| database_type == Some(crate::models::connection::DatabaseType::Kingbase)
|
||||
{
|
||||
"ADD"
|
||||
} else {
|
||||
"ADD COLUMN"
|
||||
};
|
||||
vec![format!("ALTER TABLE {table} {add_keyword} {definition}{position_clause};")]
|
||||
};
|
||||
if supports_comments
|
||||
&& matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar
|
||||
)
|
||||
&& !clean(&column.comment).is_empty()
|
||||
{
|
||||
statements.push(format!(
|
||||
"COMMENT ON COLUMN {table}.{} IS {};",
|
||||
quote_ident(dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
if dialect == StructureDialect::ClickHouse && !clean(&column.comment).is_empty() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} COMMENT COLUMN {} {};",
|
||||
quote_ident(dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
if dialect == StructureDialect::SqlServer && !clean(&column.comment).is_empty() {
|
||||
statements.extend(build_sqlserver_column_comment_sql_for_profile(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
&column.name,
|
||||
&column.comment,
|
||||
driver_profile,
|
||||
));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_drop_column_sql(dialect: StructureDialect, table: &str, column_name: &str) -> String {
|
||||
if dialect == StructureDialect::Informix {
|
||||
return format!("ALTER TABLE {table} DROP ({});", quote_ident(dialect, column_name));
|
||||
}
|
||||
format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, column_name))
|
||||
}
|
||||
|
||||
pub(super) fn column_position_clause(
|
||||
dialect: StructureDialect,
|
||||
columns: &[&EditableStructureColumn],
|
||||
index: usize,
|
||||
) -> String {
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse) {
|
||||
return String::new();
|
||||
}
|
||||
if index == 0 {
|
||||
return " FIRST".to_string();
|
||||
}
|
||||
format!(" AFTER {}", quote_ident(dialect, columns.get(index - 1).map(|column| column.name.as_str()).unwrap_or("")))
|
||||
}
|
||||
|
||||
pub(super) fn original_active_column_order(columns: &[&EditableStructureColumn]) -> Vec<String> {
|
||||
let mut original_columns: Vec<_> = columns
|
||||
.iter()
|
||||
.filter(|column| column.original.is_some() && column.original_position.is_some())
|
||||
.copied()
|
||||
.collect();
|
||||
original_columns.sort_by_key(|column| column.original_position.unwrap_or(0));
|
||||
original_columns.into_iter().map(|column| column.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Returns the ids of existing columns that must be explicitly moved to reach the target order.
|
||||
///
|
||||
/// The function keeps the longest subsequence of existing columns whose relative order is already
|
||||
/// correct, and marks only the remaining columns for FIRST/AFTER reordering SQL.
|
||||
pub(super) fn planned_existing_column_move_ids(columns: &[&EditableStructureColumn]) -> HashSet<String> {
|
||||
// Only existing columns with an original position participate in move planning.
|
||||
// Newly added columns are positioned directly from the target order.
|
||||
let reorderable_columns: Vec<_> = columns
|
||||
.iter()
|
||||
.filter_map(|column| {
|
||||
column
|
||||
.original
|
||||
.as_ref()
|
||||
.zip(column.original_position)
|
||||
.map(|_| (column.id.as_str(), column.original_position.unwrap_or(0)))
|
||||
})
|
||||
.collect();
|
||||
if reorderable_columns.len() < 2 {
|
||||
return HashSet::new();
|
||||
}
|
||||
|
||||
// Map the target order back to original positions, then keep the largest increasing subsequence.
|
||||
let original_positions: Vec<_> = reorderable_columns.iter().map(|(_, position)| *position).collect();
|
||||
// Columns inside the LIS can stay where they are; everything else needs an explicit move.
|
||||
let kept_indices: HashSet<_> = longest_increasing_subsequence_indices(&original_positions).into_iter().collect();
|
||||
|
||||
reorderable_columns
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| !kept_indices.contains(index))
|
||||
.map(|(_, (column_id, _))| column_id.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the indices of one longest increasing subsequence within `values`.
|
||||
///
|
||||
/// In the reorder planner, an increasing subsequence represents existing columns whose relative
|
||||
/// order still matches the original table layout, so they can remain untouched.
|
||||
fn longest_increasing_subsequence_indices(values: &[usize]) -> Vec<usize> {
|
||||
if values.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// O(n^2) is sufficient here because table editors deal with relatively small column counts
|
||||
// and the simpler implementation is easier to maintain.
|
||||
let mut lengths = vec![1; values.len()];
|
||||
let mut previous = vec![None; values.len()];
|
||||
let mut best_end_index = 0;
|
||||
|
||||
for current_index in 0..values.len() {
|
||||
for previous_index in 0..current_index {
|
||||
if values[previous_index] < values[current_index] && lengths[previous_index] + 1 > lengths[current_index] {
|
||||
lengths[current_index] = lengths[previous_index] + 1;
|
||||
previous[current_index] = Some(previous_index);
|
||||
}
|
||||
}
|
||||
|
||||
if lengths[current_index] > lengths[best_end_index] {
|
||||
best_end_index = current_index;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct the subsequence by following the predecessor chain backwards.
|
||||
let mut indices = Vec::new();
|
||||
let mut cursor = Some(best_end_index);
|
||||
while let Some(index) = cursor {
|
||||
indices.push(index);
|
||||
cursor = previous[index];
|
||||
}
|
||||
indices.reverse();
|
||||
indices
|
||||
}
|
||||
|
||||
pub(super) fn active_previous_column_id<'a>(columns: &[&'a EditableStructureColumn], index: usize) -> Option<&'a str> {
|
||||
if index == 0 {
|
||||
None
|
||||
} else {
|
||||
columns.get(index - 1).map(|column| column.id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn simulated_column_position_changed(
|
||||
simulated_column_order: &[String],
|
||||
column_id: &str,
|
||||
desired_previous_column_id: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(index) = simulated_column_order.iter().position(|id| id == column_id) else {
|
||||
return false;
|
||||
};
|
||||
let current_previous_column_id = if index == 0 { None } else { Some(simulated_column_order[index - 1].as_str()) };
|
||||
current_previous_column_id != desired_previous_column_id
|
||||
}
|
||||
|
||||
pub(super) fn apply_simulated_column_position(
|
||||
simulated_column_order: &mut Vec<String>,
|
||||
column_id: &str,
|
||||
desired_previous_column_id: Option<&str>,
|
||||
) {
|
||||
if let Some(index) = simulated_column_order.iter().position(|id| id == column_id) {
|
||||
simulated_column_order.remove(index);
|
||||
}
|
||||
let index = desired_previous_column_id
|
||||
.and_then(|previous_id| simulated_column_order.iter().position(|id| id == previous_id).map(|index| index + 1))
|
||||
.unwrap_or(0);
|
||||
simulated_column_order.insert(index, column_id.to_string());
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::dialect::{capabilities_for, database_label, dialect_label, StructureDialect};
|
||||
use super::types::TableStructureSqlOptions;
|
||||
use super::util::{clean, qualified_table, quote_string};
|
||||
|
||||
pub(super) fn build_table_comment_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let new_comment = options.table_comment.as_deref().unwrap_or("");
|
||||
let original_comment = options.original_table_comment.as_deref().unwrap_or("");
|
||||
if clean(new_comment) == clean(original_comment) {
|
||||
return Vec::new();
|
||||
}
|
||||
if !capabilities.comment {
|
||||
warnings.push(format!(
|
||||
"Table comments are not supported for {} from this editor; the comment change was ignored.",
|
||||
database_label(options.database_type)
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
let dialect = capabilities.dialect;
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let quoted = quote_string(&clean(new_comment));
|
||||
match dialect {
|
||||
StructureDialect::Mysql | StructureDialect::GaussdbM => {
|
||||
vec![format!("ALTER TABLE {table} COMMENT = {quoted};")]
|
||||
}
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::H2 => {
|
||||
vec![format!("COMMENT ON TABLE {table} IS {quoted};")]
|
||||
}
|
||||
StructureDialect::ClickHouse => {
|
||||
vec![format!("ALTER TABLE {table} MODIFY COMMENT {quoted};")]
|
||||
}
|
||||
StructureDialect::SqlServer => build_sqlserver_table_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
new_comment,
|
||||
options.driver_profile.as_deref(),
|
||||
),
|
||||
_ => {
|
||||
if !clean(new_comment).is_empty() {
|
||||
warnings
|
||||
.push(format!("Table comments are not supported for {} from this editor.", dialect_label(dialect)));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sqlserver_schema_name(schema: Option<&str>) -> String {
|
||||
schema.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()).unwrap_or_else(|| "dbo".to_string())
|
||||
}
|
||||
|
||||
fn build_sqlserver_extended_property_comment_sql(
|
||||
exists: &str,
|
||||
levels: &str,
|
||||
new_comment: &str,
|
||||
procedure_prefix: &str,
|
||||
) -> Vec<String> {
|
||||
let new_comment = clean(new_comment);
|
||||
if new_comment.is_empty() {
|
||||
return vec![format!(
|
||||
"IF {exists} EXEC {procedure_prefix}sp_dropextendedproperty @name=N'MS_Description', {levels};"
|
||||
)];
|
||||
}
|
||||
|
||||
let escaped_comment = new_comment.replace('\'', "''");
|
||||
vec![format!(
|
||||
"IF {exists} EXEC {procedure_prefix}sp_updateextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels} ELSE EXEC {procedure_prefix}sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels};"
|
||||
)]
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_table_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
build_sqlserver_table_comment_sql_for_profile(qualified_table, schema, table_name, new_comment, None)
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_table_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', NULL, NULL))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = 0 AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_index_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
index_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_idx = index_name.replace('\'', "''");
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', N'INDEX', N'{escaped_idx}'))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep INNER JOIN sys.indexes AS i ON i.object_id = ep.major_id AND i.index_id = ep.minor_id WHERE ep.class = 7 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND i.name = N'{escaped_idx}' AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_column_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
build_sqlserver_column_comment_sql_for_profile(qualified_table, schema, table_name, column_name, new_comment, None)
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_column_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_col = column_name.replace('\'', "''");
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', N'COLUMN', N'{escaped_col}'))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
fn is_sqlserver_legacy_profile(driver_profile: Option<&str>) -> bool {
|
||||
driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_table_comment_updates_or_adds_without_dropping() {
|
||||
let statements = build_sqlserver_table_comment_sql("[app's].[user's]", Some("app's"), "user's", "owner's 新值");
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("ep.class = 1"), "table extended-property class: {sql}");
|
||||
assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}");
|
||||
assert!(!sql.contains("sys.sp_dropextendedproperty"), "non-empty comments are not dropped first: {sql}");
|
||||
assert!(sql.contains("OBJECT_ID(N'[app''s].[user''s]')"), "object name escaping: {sql}");
|
||||
assert!(sql.contains("@value=N'owner''s 新值'"), "Unicode comment escaping: {sql}");
|
||||
assert_eq!(
|
||||
crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer)
|
||||
.len(),
|
||||
1,
|
||||
"IF/ELSE comment upsert must remain one executable statement: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_empty_column_comment_drops_only_when_present() {
|
||||
let statements = build_sqlserver_column_comment_sql("[dbo].[orders]", None, "orders", "owner'id", " ");
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("COLUMNPROPERTY(OBJECT_ID(N'[dbo].[orders]'), N'owner''id', 'ColumnId')"));
|
||||
assert!(sql.contains("sys.sp_dropextendedproperty"), "drop existing comment: {sql}");
|
||||
assert!(!sql.contains("sys.sp_updateextendedproperty"), "empty comment must not update: {sql}");
|
||||
assert!(!sql.contains("sys.sp_addextendedproperty"), "empty comment must not add: {sql}");
|
||||
assert!(sql.contains("@level0name=N'dbo'"), "default schema: {sql}");
|
||||
assert_eq!(
|
||||
crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer)
|
||||
.len(),
|
||||
1,
|
||||
"conditional comment drop must remain one executable statement: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_index_comment_uses_index_extended_property_identity() {
|
||||
let statements = build_sqlserver_index_comment_sql_for_profile(
|
||||
"[dbo].[orders]",
|
||||
None,
|
||||
"orders",
|
||||
"ix_owner's",
|
||||
"index comment",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("INNER JOIN sys.indexes AS i"), "index lookup: {sql}");
|
||||
assert!(sql.contains("ep.class = 7"), "index extended-property class: {sql}");
|
||||
assert!(sql.contains("i.name = N'ix_owner''s'"), "index name escaping: {sql}");
|
||||
assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_legacy_column_comment_uses_sql_server_2000_compatibility_syntax() {
|
||||
let statements = build_sqlserver_column_comment_sql_for_profile(
|
||||
"[dbo].[Categories]",
|
||||
Some("dbo"),
|
||||
"Categories",
|
||||
"CategoryID",
|
||||
"test",
|
||||
Some("sqlserver-legacy"),
|
||||
);
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("::fn_listextendedproperty"), "legacy property lookup: {sql}");
|
||||
assert!(sql.contains("@level0type=N'USER'"), "legacy hierarchy: {sql}");
|
||||
assert!(sql.contains("EXEC sp_updateextendedproperty"), "legacy update procedure: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sp_addextendedproperty"), "legacy add procedure: {sql}");
|
||||
assert!(
|
||||
!sql.contains("sys.extended_properties"),
|
||||
"SQL Server 2005+ catalog view leaked into legacy SQL: {sql}"
|
||||
);
|
||||
assert!(!sql.contains("EXEC sys."), "SQL Server 2005+ procedure qualification leaked into legacy SQL: {sql}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::column_format::{
|
||||
column_data_type, column_extra_clause, has_dameng_identity, is_dameng_identity_compatible_type,
|
||||
is_mysql_character_data_type, is_mysql_timestamp_type, mysql_on_update_current_timestamp_clause,
|
||||
strip_inherited_mysql_column_charsets,
|
||||
};
|
||||
use super::comments::{build_sqlserver_column_comment_sql_for_profile, build_sqlserver_table_comment_sql_for_profile};
|
||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||
use super::foreign_keys::build_foreign_key_sql_for_new_table;
|
||||
use super::indexes::build_create_index_statements;
|
||||
use super::mysql_engine::{append_mysql_table_option, validate_mysql_engine};
|
||||
use super::triggers::build_trigger_sql_for_new_table;
|
||||
use super::types::{TableStructureSqlOptions, TableStructureSqlResult};
|
||||
use super::util::{
|
||||
clean, format_default_for_sql, normalize_default, qualified_new_table, quote_ident, quote_new_ident, quote_string,
|
||||
};
|
||||
use super::validation::{validate_columns, validate_concurrent_index_scope, validate_dameng_identity};
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
fn is_sqlite_integer_family_type(data_type: &str) -> bool {
|
||||
let normalized = data_type.trim().to_ascii_lowercase();
|
||||
["int", "integer", "tinyint", "smallint", "mediumint", "bigint"]
|
||||
.iter()
|
||||
.any(|candidate| normalized == *candidate || normalized.starts_with(&format!("{candidate}(")))
|
||||
}
|
||||
|
||||
pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStructureSqlResult {
|
||||
let capabilities;
|
||||
let dialect;
|
||||
if options.is_gaussdb_m_mode {
|
||||
capabilities = super::dialect::gaussdb_m_capabilities();
|
||||
dialect = StructureDialect::GaussdbM;
|
||||
} else {
|
||||
capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
dialect = capabilities.dialect;
|
||||
}
|
||||
options.table_name = clean(&options.table_name);
|
||||
strip_inherited_mysql_column_charsets(&mut options);
|
||||
let mut warnings = Vec::new();
|
||||
warnings.extend(validate_mysql_engine(&options));
|
||||
// Fail closed: a concurrent-index request on a partitioned parent (or on an
|
||||
// existing index in a hand-built draft) is refused up front instead of
|
||||
// degrading into blocking index DDL.
|
||||
warnings.extend(validate_concurrent_index_scope(&options));
|
||||
if options.table_name.is_empty() {
|
||||
warnings.push("Table name is required.".to_string());
|
||||
}
|
||||
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
||||
if active_columns.is_empty() {
|
||||
warnings.push("At least one column is required.".to_string());
|
||||
}
|
||||
validate_columns(&active_columns, &mut warnings);
|
||||
validate_dameng_identity(&options, &active_columns, &mut warnings);
|
||||
if !warnings.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
}
|
||||
let table = qualified_new_table(options.database_type, dialect, options.schema.as_deref(), &options.table_name);
|
||||
if dialect == StructureDialect::Dameng {
|
||||
for column in &active_columns {
|
||||
if has_dameng_identity(column) && !is_dameng_identity_compatible_type(&column.data_type) {
|
||||
warnings.push(format!(
|
||||
"Dameng identity column \"{}\" must use tinyint, smallint, int, integer, bigint, number, numeric, or decimal/dec with scale 0.",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
}
|
||||
}
|
||||
if dialect == StructureDialect::Sqlite {
|
||||
let primary_key_count = active_columns.iter().filter(|column| column.is_primary_key).count();
|
||||
for column in &active_columns {
|
||||
if !column.is_primary_key || !column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false)) {
|
||||
continue;
|
||||
}
|
||||
if primary_key_count != 1 {
|
||||
warnings.push(
|
||||
"SQLite AUTOINCREMENT requires a single INTEGER PRIMARY KEY column; disable auto-increment on composite primary keys.".to_string(),
|
||||
);
|
||||
} else if !is_sqlite_integer_family_type(&column.data_type) {
|
||||
warnings.push(format!(
|
||||
"SQLite auto-increment column \"{}\" must use an integer type (normalized to INTEGER).",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
}
|
||||
}
|
||||
let mut statements = Vec::new();
|
||||
let mut column_definitions = Vec::new();
|
||||
|
||||
for column in &active_columns {
|
||||
let mut data_type = column_data_type(dialect, column);
|
||||
// SQLite accepts AUTOINCREMENT only on an exact INTEGER PRIMARY KEY,
|
||||
// so integer-family aliases are normalized when auto-increment is on.
|
||||
if dialect == StructureDialect::Sqlite
|
||||
&& column.is_primary_key
|
||||
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false))
|
||||
{
|
||||
data_type = "INTEGER".to_string();
|
||||
}
|
||||
let mut parts = vec![quote_new_ident(options.database_type, dialect, &column.name), data_type];
|
||||
if options.database_type == Some(DatabaseType::Mysql) && is_mysql_character_data_type(&column.data_type) {
|
||||
if !column.character_set.trim().is_empty() {
|
||||
parts.push(format!("CHARACTER SET {}", quote_ident(dialect, &column.character_set)));
|
||||
}
|
||||
if !column.collation.trim().is_empty() {
|
||||
parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation)));
|
||||
}
|
||||
}
|
||||
if dialect == StructureDialect::Sqlite
|
||||
&& column.is_primary_key
|
||||
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false))
|
||||
{
|
||||
parts.push("PRIMARY KEY".to_string());
|
||||
} else if !column.is_nullable
|
||||
&& !column.is_primary_key
|
||||
&& !matches!(dialect, StructureDialect::ClickHouse | StructureDialect::ManticoreSearch)
|
||||
{
|
||||
parts.push("NOT NULL".to_string());
|
||||
} else if column.is_nullable
|
||||
&& !column.is_primary_key
|
||||
&& dialect == StructureDialect::Mysql
|
||||
&& is_mysql_timestamp_type(&column.data_type)
|
||||
{
|
||||
parts.push("NULL".to_string());
|
||||
}
|
||||
if let Some(extra_clause) = column_extra_clause(dialect, column) {
|
||||
parts.push(extra_clause);
|
||||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() && dialect != StructureDialect::ManticoreSearch {
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
}
|
||||
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
|
||||
if on_update && dialect == StructureDialect::Mysql {
|
||||
parts.push(mysql_on_update_current_timestamp_clause(&column.data_type));
|
||||
}
|
||||
}
|
||||
if dialect == StructureDialect::Mysql && capabilities.comment && !clean(&column.comment).is_empty() {
|
||||
parts.push(format!("COMMENT {}", quote_string(&clean(&column.comment))));
|
||||
}
|
||||
column_definitions.push(parts.join(" "));
|
||||
}
|
||||
|
||||
let pk_columns: Vec<_> = active_columns
|
||||
.iter()
|
||||
.filter(|column| {
|
||||
column.is_primary_key
|
||||
&& dialect != StructureDialect::ManticoreSearch
|
||||
&& !(dialect == StructureDialect::Sqlite
|
||||
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false)))
|
||||
})
|
||||
.collect();
|
||||
if !pk_columns.is_empty() {
|
||||
let pk_list = pk_columns
|
||||
.iter()
|
||||
.map(|column| quote_new_ident(options.database_type, dialect, &column.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
column_definitions.push(format!("PRIMARY KEY ({pk_list})"));
|
||||
}
|
||||
|
||||
statements.push(format!("CREATE TABLE {table} (\n {}\n);", column_definitions.join(",\n ")));
|
||||
|
||||
if let Some(engine) = options.mysql_engine.as_deref().map(str::trim).filter(|engine| !engine.is_empty()) {
|
||||
if let Some(statement) = statements.last_mut() {
|
||||
append_mysql_table_option(statement, &format!("ENGINE = {engine}"));
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.comment {
|
||||
let table_comment = clean(options.table_comment.as_deref().unwrap_or(""));
|
||||
if !table_comment.is_empty() {
|
||||
if matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM) {
|
||||
if let Some(last) = statements.last_mut() {
|
||||
append_mysql_table_option(last, &format!("COMMENT = {}", quote_string(&table_comment)));
|
||||
}
|
||||
} else if matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::H2
|
||||
) {
|
||||
statements.push(format!("COMMENT ON TABLE {table} IS {};", quote_string(&table_comment)));
|
||||
} else if dialect == StructureDialect::ClickHouse {
|
||||
statements.push(format!("ALTER TABLE {table} MODIFY COMMENT {};", quote_string(&table_comment)));
|
||||
} else if dialect == StructureDialect::SqlServer {
|
||||
statements.extend(build_sqlserver_table_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&table_comment,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.comment
|
||||
&& matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::H2
|
||||
)
|
||||
{
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.push(format!(
|
||||
"COMMENT ON COLUMN {table}.{} IS {};",
|
||||
quote_new_ident(options.database_type, dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if capabilities.comment && dialect == StructureDialect::ClickHouse {
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} COMMENT COLUMN {} {};",
|
||||
quote_new_ident(options.database_type, dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if capabilities.comment && dialect == StructureDialect::SqlServer {
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.extend(build_sqlserver_column_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&column.name,
|
||||
&column.comment,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for index in options.indexes.iter().filter(|index| !index.marked_for_drop && !index.is_primary) {
|
||||
if !capabilities.create_index {
|
||||
warnings.push(format!(
|
||||
"Creating indexes is not supported for {} from this editor.",
|
||||
database_label(options.database_type)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
&mut warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
false,
|
||||
capabilities.index_concurrent,
|
||||
true,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
statements.extend(build_foreign_key_sql_for_new_table(&options, &mut warnings));
|
||||
statements.extend(build_trigger_sql_for_new_table(&options, &mut warnings));
|
||||
|
||||
TableStructureSqlResult { statements, warnings }
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum StructureDialect {
|
||||
Mysql,
|
||||
Doris,
|
||||
Postgres,
|
||||
Sqlite,
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
DuckDb,
|
||||
SqlServer,
|
||||
Oracle,
|
||||
Dameng,
|
||||
// 神通 Oscar:Oracle 兼容方言,且支持 ALTER TABLE DROP/ADD PRIMARY KEY(与 Dameng 一致,
|
||||
// 不同于 Oracle)。DDL 生成行为与 Dameng 共享分支;单独成 dialect 以保证 label/反向映射准确。
|
||||
Oscar,
|
||||
H2,
|
||||
ClickHouse,
|
||||
ManticoreSearch,
|
||||
Informix,
|
||||
Questdb,
|
||||
// GaussDB M 模式:MySQL 兼容方言,使用反引号引用标识符。DDL 行为接近 MySQL,
|
||||
// 但使用 UBTREE 索引类型(而非 BTREE),不支持 INCLUDE/FILTER 等。
|
||||
// DROP INDEX 使用 PostgreSQL 风格(DROP INDEX name),而非 MySQL 的 DROP INDEX name ON table。
|
||||
GaussdbM,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct TableStructureCapabilities {
|
||||
pub(super) dialect: StructureDialect,
|
||||
pub(super) add_column: bool,
|
||||
pub(super) drop_column: bool,
|
||||
pub(super) rename_column: bool,
|
||||
pub(super) alter_existing_column: bool,
|
||||
pub(super) reorder_column: bool,
|
||||
pub(super) comment: bool,
|
||||
pub(super) create_index: bool,
|
||||
pub(super) drop_index: bool,
|
||||
pub(super) rebuild_index: bool,
|
||||
pub(super) index_type: bool,
|
||||
pub(super) index_include: bool,
|
||||
pub(super) index_filter: bool,
|
||||
pub(super) index_comment: bool,
|
||||
pub(super) index_concurrent: bool,
|
||||
pub(super) add_primary_key: bool,
|
||||
pub(super) alter_primary_key: bool,
|
||||
pub(super) foreign_key: bool,
|
||||
}
|
||||
|
||||
impl Default for TableStructureCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dialect: StructureDialect::Unsupported,
|
||||
add_column: false,
|
||||
drop_column: false,
|
||||
rename_column: false,
|
||||
alter_existing_column: false,
|
||||
reorder_column: false,
|
||||
comment: false,
|
||||
create_index: false,
|
||||
drop_index: false,
|
||||
rebuild_index: false,
|
||||
index_type: false,
|
||||
index_include: false,
|
||||
index_filter: false,
|
||||
index_comment: false,
|
||||
index_concurrent: false,
|
||||
add_primary_key: false,
|
||||
alter_primary_key: false,
|
||||
foreign_key: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn gaussdb_m_capabilities() -> TableStructureCapabilities {
|
||||
TableStructureCapabilities {
|
||||
dialect: StructureDialect::GaussdbM,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_comment: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
..TableStructureCapabilities::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn capabilities_for(
|
||||
database_type: Option<DatabaseType>,
|
||||
driver_profile: Option<&str>,
|
||||
) -> TableStructureCapabilities {
|
||||
let base = TableStructureCapabilities::default();
|
||||
match database_type {
|
||||
Some(
|
||||
DatabaseType::Mysql
|
||||
| DatabaseType::StarRocks
|
||||
| DatabaseType::Goldendb
|
||||
| DatabaseType::Sundb
|
||||
| DatabaseType::Databend,
|
||||
) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Mysql,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_comment: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
foreign_key: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Doris) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Doris,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
..base
|
||||
},
|
||||
// GBase 8s is Informix-compatible, unlike the rest of the `Gbase`
|
||||
// family (GBase 8a and friends) which speak MySQL wire protocol and
|
||||
// SQL dialect. `driver_profile` is the only signal that distinguishes
|
||||
// them (see the same check in `table_export.rs` / `table_select.rs`).
|
||||
Some(DatabaseType::Gbase) if driver_profile.is_some_and(|profile| profile.eq_ignore_ascii_case("gbase8s")) => {
|
||||
TableStructureCapabilities {
|
||||
dialect: StructureDialect::Informix,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
}
|
||||
}
|
||||
Some(DatabaseType::Gbase) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Mysql,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
reorder_column: true,
|
||||
..base
|
||||
},
|
||||
Some(
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kwdb
|
||||
| DatabaseType::OpenGauss
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Uxdb
|
||||
| DatabaseType::Vastbase
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Firebird,
|
||||
) => {
|
||||
let mut caps = TableStructureCapabilities {
|
||||
dialect: StructureDialect::Postgres,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_include: true,
|
||||
index_filter: true,
|
||||
index_comment: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
foreign_key: true,
|
||||
..base
|
||||
};
|
||||
// CREATE INDEX CONCURRENTLY is PostgreSQL-specific. Other PostgreSQL-family
|
||||
// engines (Kingbase, GaussDB, OpenGauss, HighGo, UXDB, Vastbase, KWDB,
|
||||
// Firebird) share the Postgres dialect but have not been verified against a
|
||||
// real environment, so they keep this capability off until individually
|
||||
// validated.
|
||||
if database_type == Some(DatabaseType::Postgres) {
|
||||
caps.index_concurrent = true;
|
||||
}
|
||||
caps
|
||||
}
|
||||
Some(DatabaseType::Questdb) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Questdb,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: false,
|
||||
comment: false,
|
||||
create_index: false,
|
||||
drop_index: false,
|
||||
rebuild_index: false,
|
||||
index_type: false,
|
||||
index_include: false,
|
||||
index_filter: false,
|
||||
index_comment: false,
|
||||
index_concurrent: false,
|
||||
add_primary_key: false,
|
||||
alter_primary_key: false,
|
||||
foreign_key: false,
|
||||
},
|
||||
Some(DatabaseType::Redshift | DatabaseType::Vertica) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Postgres,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Sqlite | DatabaseType::Rqlite) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Sqlite,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_filter: true,
|
||||
..base
|
||||
},
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
Some(DatabaseType::DuckDb) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::DuckDb,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::SqlServer) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::SqlServer,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_include: true,
|
||||
index_filter: true,
|
||||
index_comment: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Dameng) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Dameng,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
..base
|
||||
},
|
||||
// 神通 Oscar v7:Oracle 兼容,实测支持 ALTER TABLE ADD/MODIFY/DROP/RENAME COLUMN、
|
||||
// DROP/ADD PRIMARY KEY、COMMENT ON、CREATE/DROP INDEX(见 issue #5505 探测)。
|
||||
Some(DatabaseType::Oscar) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oscar,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Iris) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oracle,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
// IRIS supports %DESCRIPTION while defining tables/columns, but cannot alter existing descriptions.
|
||||
comment: false,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Oracle) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oracle,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
add_primary_key: true,
|
||||
foreign_key: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::OceanbaseOracle | DatabaseType::Yashandb | DatabaseType::Xugu) => {
|
||||
TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oracle,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
..base
|
||||
}
|
||||
}
|
||||
Some(DatabaseType::H2) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::H2,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::ClickHouse) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::ClickHouse,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: true,
|
||||
comment: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::ManticoreSearch) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::ManticoreSearch,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Informix) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Informix,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_oracle_like(dialect: StructureDialect) -> bool {
|
||||
matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar)
|
||||
}
|
||||
|
||||
pub(super) fn database_label(database_type: Option<DatabaseType>) -> String {
|
||||
database_type
|
||||
.map(|database_type| {
|
||||
serde_json::to_value(database_type)
|
||||
.ok()
|
||||
.and_then(|value| value.as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "this database".to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "this database".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn dialect_label(dialect: StructureDialect) -> String {
|
||||
match dialect {
|
||||
StructureDialect::Mysql => "mysql",
|
||||
StructureDialect::Doris => "doris",
|
||||
StructureDialect::Postgres => "postgres",
|
||||
StructureDialect::Sqlite => "sqlite",
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
StructureDialect::DuckDb => "duckdb",
|
||||
StructureDialect::SqlServer => "sqlserver",
|
||||
StructureDialect::Oracle => "oracle",
|
||||
StructureDialect::Dameng => "dameng",
|
||||
StructureDialect::Oscar => "oscar",
|
||||
StructureDialect::H2 => "h2",
|
||||
StructureDialect::ClickHouse => "clickhouse",
|
||||
StructureDialect::ManticoreSearch => "manticoresearch",
|
||||
StructureDialect::Informix => "informix",
|
||||
StructureDialect::GaussdbM => "gaussdb-m",
|
||||
StructureDialect::Questdb => "questdb",
|
||||
StructureDialect::Unsupported => "this database",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn database_type_for_dialect(dialect: StructureDialect) -> Option<DatabaseType> {
|
||||
match dialect {
|
||||
StructureDialect::Mysql => Some(DatabaseType::Mysql),
|
||||
StructureDialect::Doris => Some(DatabaseType::Doris),
|
||||
StructureDialect::Postgres => Some(DatabaseType::Postgres),
|
||||
StructureDialect::Sqlite => Some(DatabaseType::Sqlite),
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
StructureDialect::DuckDb => Some(DatabaseType::DuckDb),
|
||||
StructureDialect::SqlServer => Some(DatabaseType::SqlServer),
|
||||
StructureDialect::Oracle => Some(DatabaseType::Oracle),
|
||||
StructureDialect::Dameng => Some(DatabaseType::Dameng),
|
||||
StructureDialect::Oscar => Some(DatabaseType::Oscar),
|
||||
StructureDialect::H2 => Some(DatabaseType::H2),
|
||||
StructureDialect::ClickHouse => Some(DatabaseType::ClickHouse),
|
||||
StructureDialect::ManticoreSearch => Some(DatabaseType::ManticoreSearch),
|
||||
StructureDialect::Informix => Some(DatabaseType::Informix),
|
||||
StructureDialect::Questdb => Some(DatabaseType::Questdb),
|
||||
StructureDialect::GaussdbM => Some(DatabaseType::Mysql),
|
||||
StructureDialect::Unsupported => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::dialect::{database_label, StructureDialect};
|
||||
use super::types::{EditableStructureForeignKey, ForeignKeyInfo, TableStructureSqlOptions};
|
||||
use super::util::{clean, qualified_new_table, qualified_table, quote_ident, quote_new_ident};
|
||||
|
||||
pub(super) fn build_foreign_key_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
build_foreign_key_sql_with_mode(options, warnings, false)
|
||||
}
|
||||
|
||||
pub(super) fn build_foreign_key_sql_for_new_table(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
build_foreign_key_sql_with_mode(options, warnings, true)
|
||||
}
|
||||
|
||||
fn build_foreign_key_sql_with_mode(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Vec<String> {
|
||||
if options.foreign_keys.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let database_label = database_label(options.database_type);
|
||||
let capabilities = super::dialect::capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let dialect = capabilities.dialect;
|
||||
if !capabilities.foreign_key {
|
||||
if has_foreign_key_edits(&options.foreign_keys) {
|
||||
warnings.push(format!("Editing foreign keys is not supported for {database_label} from this editor."));
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let table = if for_new_table {
|
||||
qualified_new_table(options.database_type, dialect, options.schema.as_deref(), &options.table_name)
|
||||
} else {
|
||||
qualified_table(dialect, options.schema.as_deref(), &options.table_name)
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for foreign_key in &options.foreign_keys {
|
||||
if foreign_key.marked_for_drop {
|
||||
if let Some(original) = &foreign_key.original {
|
||||
statements.push(drop_foreign_key_sql(dialect, &table, &original.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(original) = &foreign_key.original {
|
||||
if !has_foreign_key_change(foreign_key, original) {
|
||||
continue;
|
||||
}
|
||||
statements.push(drop_foreign_key_sql(dialect, &table, &original.name));
|
||||
}
|
||||
|
||||
if let Some(sql) =
|
||||
create_foreign_key_sql(options.database_type, dialect, &table, foreign_key, warnings, for_new_table)
|
||||
{
|
||||
statements.push(sql);
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn has_foreign_key_edits(foreign_keys: &[EditableStructureForeignKey]) -> bool {
|
||||
foreign_keys.iter().any(|foreign_key| {
|
||||
if foreign_key.marked_for_drop {
|
||||
return foreign_key.original.is_some();
|
||||
}
|
||||
|
||||
match &foreign_key.original {
|
||||
Some(original) => has_foreign_key_change(foreign_key, original),
|
||||
None => has_foreign_key_definition(foreign_key),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn has_foreign_key_definition(foreign_key: &EditableStructureForeignKey) -> bool {
|
||||
!clean(&foreign_key.name).is_empty()
|
||||
|| !clean(&foreign_key.column).is_empty()
|
||||
|| !clean(&foreign_key.ref_schema).is_empty()
|
||||
|| !clean(&foreign_key.ref_table).is_empty()
|
||||
|| !clean(&foreign_key.ref_column).is_empty()
|
||||
|| !clean(&foreign_key.on_update).is_empty()
|
||||
|| !clean(&foreign_key.on_delete).is_empty()
|
||||
}
|
||||
|
||||
fn has_foreign_key_change(foreign_key: &EditableStructureForeignKey, original: &ForeignKeyInfo) -> bool {
|
||||
clean(&foreign_key.name) != clean(&original.name)
|
||||
|| clean(&foreign_key.column) != clean(&original.column)
|
||||
|| clean(&foreign_key.ref_schema) != clean(original.ref_schema.as_deref().unwrap_or(""))
|
||||
|| clean(&foreign_key.ref_table) != clean(&original.ref_table)
|
||||
|| clean(&foreign_key.ref_column) != clean(&original.ref_column)
|
||||
|| normalize_action(&foreign_key.on_update) != normalize_action(original.on_update.as_deref().unwrap_or(""))
|
||||
|| normalize_action(&foreign_key.on_delete) != normalize_action(original.on_delete.as_deref().unwrap_or(""))
|
||||
}
|
||||
|
||||
fn drop_foreign_key_sql(dialect: StructureDialect, table: &str, name: &str) -> String {
|
||||
match dialect {
|
||||
StructureDialect::Mysql => format!("ALTER TABLE {table} DROP FOREIGN KEY {};", quote_ident(dialect, name)),
|
||||
StructureDialect::Postgres | StructureDialect::Oracle => {
|
||||
format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, name))
|
||||
}
|
||||
_ => unreachable!("foreign key SQL requested for unsupported dialect"),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_foreign_key_sql(
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
foreign_key: &EditableStructureForeignKey,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Option<String> {
|
||||
let name = clean(&foreign_key.name);
|
||||
let columns = identifier_list(&foreign_key.column);
|
||||
let ref_table = clean(&foreign_key.ref_table);
|
||||
let ref_columns = identifier_list(&foreign_key.ref_column);
|
||||
if name.is_empty() || columns.is_empty() || ref_table.is_empty() || ref_columns.is_empty() {
|
||||
warnings.push("Foreign key name, column, referenced table, and referenced column are required.".to_string());
|
||||
return None;
|
||||
}
|
||||
if columns.len() != ref_columns.len() {
|
||||
warnings.push(format!("Foreign key \"{name}\" must have the same number of local and referenced columns."));
|
||||
return None;
|
||||
}
|
||||
|
||||
let ref_target = if clean(&foreign_key.ref_schema).is_empty() {
|
||||
quote_ident(dialect, &ref_table)
|
||||
} else {
|
||||
format!("{}.{}", quote_ident(dialect, &foreign_key.ref_schema), quote_ident(dialect, &ref_table))
|
||||
};
|
||||
|
||||
let constraint_name =
|
||||
if for_new_table { quote_new_ident(database_type, dialect, &name) } else { quote_ident(dialect, &name) };
|
||||
let local_columns =
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if for_new_table {
|
||||
quote_new_ident(database_type, dialect, column)
|
||||
} else {
|
||||
quote_ident(dialect, column)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let referenced_columns =
|
||||
ref_columns.iter().map(|column| quote_ident(dialect, column)).collect::<Vec<_>>().join(", ");
|
||||
let mut sql = format!(
|
||||
"ALTER TABLE {table} ADD CONSTRAINT {constraint_name} FOREIGN KEY ({local_columns}) REFERENCES {ref_target} ({referenced_columns})"
|
||||
);
|
||||
|
||||
if let Some(action) = action_clause(dialect, "ON DELETE", &foreign_key.on_delete, warnings) {
|
||||
sql.push(' ');
|
||||
sql.push_str(&action);
|
||||
}
|
||||
if let Some(action) = action_clause(dialect, "ON UPDATE", &foreign_key.on_update, warnings) {
|
||||
sql.push(' ');
|
||||
sql.push_str(&action);
|
||||
}
|
||||
sql.push(';');
|
||||
Some(sql)
|
||||
}
|
||||
|
||||
fn action_clause(dialect: StructureDialect, prefix: &str, value: &str, warnings: &mut Vec<String>) -> Option<String> {
|
||||
let action = normalize_action(value);
|
||||
if action.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if dialect == StructureDialect::Oracle {
|
||||
if prefix == "ON UPDATE" {
|
||||
if action != "NO ACTION" {
|
||||
warnings.push(format!("Oracle does not support {prefix} {action}."));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
return match action.as_str() {
|
||||
"CASCADE" | "SET NULL" => Some(format!("{prefix} {action}")),
|
||||
"NO ACTION" => None,
|
||||
_ => {
|
||||
warnings.push(format!("Unsupported Oracle foreign key action \"{}\".", clean(value)));
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
match action.as_str() {
|
||||
"CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION" => Some(format!("{prefix} {action}")),
|
||||
_ => {
|
||||
warnings.push(format!("Unsupported foreign key action \"{}\".", clean(value)));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_action(value: &str) -> String {
|
||||
clean(value).split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn identifier_list(value: &str) -> Vec<String> {
|
||||
value.split(',').map(clean).filter(|value| !value.is_empty()).collect()
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
use super::comments::build_sqlserver_index_comment_sql_for_profile;
|
||||
use super::dialect::{capabilities_for, database_label, database_type_for_dialect, dialect_label, StructureDialect};
|
||||
use super::types::{EditableStructureIndex, IndexInfo, TableStructureSqlOptions};
|
||||
use super::util::{clean, qualified_table, quote_ident, quote_new_ident, quote_string};
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities;
|
||||
let dialect;
|
||||
if options.is_gaussdb_m_mode {
|
||||
let caps = super::dialect::gaussdb_m_capabilities();
|
||||
capabilities = caps;
|
||||
dialect = StructureDialect::GaussdbM;
|
||||
} else {
|
||||
let caps = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
capabilities = caps;
|
||||
dialect = caps.dialect;
|
||||
}
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let database_label = database_label(options.database_type);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for index in &options.indexes {
|
||||
if index.marked_for_drop {
|
||||
let Some(original) = &index.original else {
|
||||
continue;
|
||||
};
|
||||
if !capabilities.drop_index {
|
||||
warnings.push(format!("Dropping indexes is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if original.is_primary {
|
||||
warnings.push(format!("Primary index \"{}\" cannot be dropped from this editor.", original.name));
|
||||
continue;
|
||||
}
|
||||
if is_dameng_constraint_backed(options.database_type, original) {
|
||||
statements.push(build_dameng_drop_constraint_sql(dialect, &table, &original.name));
|
||||
continue;
|
||||
}
|
||||
statements.push(build_drop_index_sql(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&original.name,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(original) = &index.original {
|
||||
if !has_existing_index_change(index) {
|
||||
continue;
|
||||
}
|
||||
if !capabilities.rebuild_index || !capabilities.drop_index || !capabilities.create_index {
|
||||
warnings
|
||||
.push(format!("Editing existing indexes is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if original.is_primary {
|
||||
warnings.push(format!("Primary index \"{}\" cannot be edited from this editor.", original.name));
|
||||
continue;
|
||||
}
|
||||
if is_dameng_constraint_backed(options.database_type, original) {
|
||||
statements.extend(build_dameng_constraint_index_edit(
|
||||
options,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
original,
|
||||
capabilities.index_concurrent,
|
||||
warnings,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let or_replace =
|
||||
options.database_type == Some(DatabaseType::Dameng) && clean(&index.name) == clean(&original.name);
|
||||
if !or_replace {
|
||||
statements.push(build_drop_index_sql(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&original.name,
|
||||
));
|
||||
}
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
or_replace,
|
||||
capabilities.index_concurrent,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if !capabilities.create_index {
|
||||
warnings.push(format!("Creating indexes is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
false,
|
||||
capabilities.index_concurrent,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
/// Dameng builds the index behind a PRIMARY KEY / UNIQUE constraint as a "virtual" index
|
||||
/// owned by that constraint. Index-level DDL against such an index — `DROP INDEX` as much as
|
||||
/// `CREATE OR REPLACE INDEX` — is rejected with a misleading "no permission to drop index"
|
||||
/// error even for the owning user (#7959); it can only be changed through
|
||||
/// `ALTER TABLE ... ADD/DROP CONSTRAINT`, the same way Dameng primary keys are already
|
||||
/// handled in `columns.rs`.
|
||||
///
|
||||
/// A "real" unique index (`CREATE UNIQUE INDEX`, which is also what this editor emits for a
|
||||
/// newly created unique index) has no constraint behind it, is not reported as
|
||||
/// constraint-backed by introspection, and keeps the index-level path — constraint DDL would
|
||||
/// fail on it with "constraint does not exist".
|
||||
fn is_dameng_constraint_backed(database_type: Option<DatabaseType>, original: &IndexInfo) -> bool {
|
||||
database_type == Some(DatabaseType::Dameng) && original.constraint_backed
|
||||
}
|
||||
|
||||
fn build_dameng_drop_constraint_sql(dialect: StructureDialect, table: &str, constraint_name: &str) -> String {
|
||||
format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, constraint_name))
|
||||
}
|
||||
|
||||
/// Rewrites an edit of a constraint-backed Dameng index as constraint DDL: drop the
|
||||
/// constraint, then re-add it as `UNIQUE` (still unique) or replace it with an ordinary
|
||||
/// index (downgraded to a plain index, where index-level DDL is fine again because the
|
||||
/// constraint is gone).
|
||||
fn build_dameng_constraint_index_edit(
|
||||
options: &TableStructureSqlOptions,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
index: &EditableStructureIndex,
|
||||
original: &IndexInfo,
|
||||
concurrently_supported: bool,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let name = clean(&index.name);
|
||||
let columns: Vec<String> =
|
||||
index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect();
|
||||
// Same guard as `build_create_index_statements`: an empty name or an empty column list
|
||||
// cannot produce a valid replacement (`validate_draft` already reports it as a warning).
|
||||
// Emitting only the DROP would silently delete the constraint, so skip the edit entirely.
|
||||
if name.is_empty() || columns.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut statements = vec![build_dameng_drop_constraint_sql(dialect, table, &original.name)];
|
||||
if !index.is_unique {
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
false,
|
||||
concurrently_supported,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
return statements;
|
||||
}
|
||||
|
||||
// `build_create_index_statements` would honor BITMAP for Dameng, but a unique constraint
|
||||
// always builds a normal index behind itself, so the type cannot be carried over.
|
||||
if normalized_index_type(index) == "BITMAP" {
|
||||
warnings.push(format!(
|
||||
"Index type BITMAP is ignored for unique index \"{name}\": Dameng enforces it with a unique constraint, whose index cannot be a bitmap index."
|
||||
));
|
||||
}
|
||||
let cols = columns.iter().map(|column| quote_ident(dialect, column)).collect::<Vec<_>>().join(", ");
|
||||
statements.push(format!("ALTER TABLE {table} ADD CONSTRAINT {} UNIQUE ({cols});", quote_ident(dialect, &name)));
|
||||
statements
|
||||
}
|
||||
|
||||
pub fn has_existing_index_change(index: &EditableStructureIndex) -> bool {
|
||||
let Some(original) = &index.original else {
|
||||
return false;
|
||||
};
|
||||
clean(&index.name) != clean(&original.name)
|
||||
|| index_list_changed(&index.columns, Some(&original.columns))
|
||||
|| index.is_unique != original.is_unique
|
||||
|| normalized_index_type(index) != clean(original.index_type.as_deref().unwrap_or("")).to_ascii_uppercase()
|
||||
|| index_list_changed(&index.included_columns, original.included_columns.as_ref())
|
||||
|| clean(&index.filter) != clean(original.filter.as_deref().unwrap_or(""))
|
||||
|| clean(&index.comment) != clean(original.comment.as_deref().unwrap_or(""))
|
||||
|| index_opclasses_changed(
|
||||
&index.column_opclasses,
|
||||
&original.column_opclasses,
|
||||
&index.columns,
|
||||
&original.columns,
|
||||
)
|
||||
}
|
||||
|
||||
/// Compare opclass arrays positionally, skipping expression columns.
|
||||
/// When the column list has changed we already detect that above; here we only
|
||||
/// compare opclasses for columns that exist in both the edited and original lists.
|
||||
fn index_opclasses_changed(
|
||||
next: &[Option<String>],
|
||||
previous: &[Option<String>],
|
||||
next_cols: &[String],
|
||||
prev_cols: &[String],
|
||||
) -> bool {
|
||||
// If the edited side has explicit opclasses, compare positionally.
|
||||
if !next.is_empty() {
|
||||
// Length mismatch (shouldn't happen when columns are in lockstep, but
|
||||
// defend against it): any difference in length is a change.
|
||||
if next.len() != previous.len() {
|
||||
return true;
|
||||
}
|
||||
return next.iter().zip(previous.iter()).any(|(n, p)| {
|
||||
let n_flat = n.as_deref().map(clean).filter(|v| !v.is_empty());
|
||||
let p_flat = p.as_deref().map(clean).filter(|v| !v.is_empty());
|
||||
n_flat != p_flat
|
||||
});
|
||||
}
|
||||
// If the edited side has no opclasses, check whether the original side
|
||||
// had any non-default opclasses for columns still present.
|
||||
if previous.iter().any(|o| o.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false)) {
|
||||
let prev_set: std::collections::HashSet<_> = prev_cols.iter().map(|c| clean(c)).collect();
|
||||
return next_cols.iter().zip(previous.iter()).any(|(nc, po)| {
|
||||
po.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false) && prev_set.contains(&clean(nc))
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn index_list_changed(next: &[String], previous: Option<&Vec<String>>) -> bool {
|
||||
let next_clean: Vec<_> = next.iter().map(|value| clean(value)).filter(|value| !value.is_empty()).collect();
|
||||
let previous_clean: Vec<_> =
|
||||
previous.unwrap_or(&Vec::new()).iter().map(|value| clean(value)).filter(|value| !value.is_empty()).collect();
|
||||
next_clean.len() != previous_clean.len()
|
||||
|| next_clean.iter().enumerate().any(|(index, value)| previous_clean.get(index) != Some(value))
|
||||
}
|
||||
|
||||
pub(super) fn normalized_index_type(index: &EditableStructureIndex) -> String {
|
||||
clean(&index.index_type).to_ascii_uppercase()
|
||||
}
|
||||
|
||||
pub(super) fn mysql_index_parts(index_type: &str) -> (String, String) {
|
||||
match index_type.to_ascii_uppercase().as_str() {
|
||||
"FULLTEXT" | "SPATIAL" => (format!("{} ", index_type.to_ascii_uppercase()), String::new()),
|
||||
"RTREE" => ("SPATIAL ".to_string(), String::new()),
|
||||
"BTREE" | "HASH" => (String::new(), format!(" USING {}", index_type.to_ascii_uppercase())),
|
||||
_ => (String::new(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn gaussdbm_index_parts(index_type: &str) -> (String, String) {
|
||||
match index_type.to_ascii_uppercase().as_str() {
|
||||
"BTREE" | "UBTREE" => (String::new(), " USING UBTREE".to_string()),
|
||||
"HASH" => (String::new(), " USING HASH".to_string()),
|
||||
_ => (String::new(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn gaussdbm_index_column_sql(column: &str, is_expression: bool) -> String {
|
||||
let trimmed = column.trim();
|
||||
if is_expression {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if let Some((name, suffix)) = trimmed.rsplit_once('(') {
|
||||
if let Some(length) = suffix.strip_suffix(')') {
|
||||
if !name.trim().is_empty() && !length.is_empty() && length.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return format!("{}({length})", quote_ident(StructureDialect::GaussdbM, name.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
quote_ident(StructureDialect::GaussdbM, trimmed)
|
||||
}
|
||||
|
||||
fn mysql_index_column_sql(column: &str) -> String {
|
||||
let trimmed = column.trim();
|
||||
// Keep the wrapped expression from MySQL metadata instead of quoting it as a column identifier.
|
||||
if trimmed.starts_with("((") && trimmed.ends_with("))") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
quote_ident(StructureDialect::Mysql, column)
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_index_column_sql(column: &str, is_expression: bool, opclass: Option<&str>) -> String {
|
||||
// The base key text: a real column is quoted as an identifier; an expression/functional
|
||||
// key part arrives as raw expression text (from the per-column `pg_get_indexdef`, which
|
||||
// omits the opclass — see `list_indexes_with_sql`), so quoting the whole thing as an
|
||||
// identifier would turn it into a nonexistent column reference (#6295).
|
||||
let base =
|
||||
if is_expression { column.trim().to_string() } else { quote_ident(StructureDialect::Postgres, column.trim()) };
|
||||
// The opclass is read separately from `pg_index.indclass` for every key position
|
||||
// (including expression keys) and appended uniformly — it never lives inside the
|
||||
// expression text, so there is no duplication risk.
|
||||
match opclass.filter(|o| !o.is_empty()) {
|
||||
Some(opc) => format!("{} {}", base, opc),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-key expression provenance for `columns` (the edited/current key list), computed once for
|
||||
/// the whole index. The editor only lets users toggle real table columns onto an index (see
|
||||
/// TableStructureEditor.vue's column checkbox list) — there is no free-text expression input —
|
||||
/// so an edited key part is only ever an expression when it corresponds to one from the original
|
||||
/// introspected snapshot.
|
||||
///
|
||||
/// Matches each edited key part against the first *not yet claimed* original key part with the
|
||||
/// same text, rather than the first textual match overall: a real column whose name happens to
|
||||
/// equal another key's expression text (or two key parts with identical text) can otherwise steal
|
||||
/// the wrong original's provenance. Consuming each original slot at most once keeps provenance
|
||||
/// tied to its true ordinal position instead of being re-derived by scanning for a text match
|
||||
/// (#6312 review). A key without explicit provenance is treated as a real column: the editor can
|
||||
/// only add table columns, and guessing from the identifier text breaks valid quoted names.
|
||||
fn key_expression_flags(index: &EditableStructureIndex, columns: &[String]) -> Vec<bool> {
|
||||
let original = match &index.original {
|
||||
Some(original) if !original.key_is_expression.is_empty() => original,
|
||||
_ => return vec![false; columns.len()],
|
||||
};
|
||||
let mut consumed = vec![false; original.columns.len()];
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let claimed = original
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, original_column)| !consumed[*i] && *original_column == column);
|
||||
match claimed {
|
||||
Some((i, _)) => {
|
||||
consumed[i] = true;
|
||||
original.key_is_expression.get(i).copied().unwrap_or(false)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per-key operator class provenance for `columns` (the edited/current key list).
|
||||
///
|
||||
/// Honors explicit per-key opclasses (`index.column_opclasses`) positionally when
|
||||
/// they are aligned to `columns` (non-empty, same length) and represent a genuine
|
||||
/// edit: either there is no `original` (a newly created index, where the UI
|
||||
/// authors `column_opclasses` in lockstep with `columns`), or the edited
|
||||
/// opclasses differ from the round-tripped `original.column_opclasses`. A stale
|
||||
/// copy left behind by a column toggle/reorder — still equal to the original —
|
||||
/// falls through to the order-independent text-matching below, so opclasses are
|
||||
/// never misassigned positionally before the editor maintains `column_opclasses`
|
||||
/// in lockstep with `columns`. Once that lockstep is in place the guard always
|
||||
/// passes and the honor branch is authoritative.
|
||||
fn key_opclasses(index: &EditableStructureIndex, columns: &[String]) -> Vec<Option<String>> {
|
||||
if !index.column_opclasses.is_empty()
|
||||
&& index.column_opclasses.len() == columns.len()
|
||||
&& index.original.as_ref().is_none_or(|original| original.column_opclasses != index.column_opclasses)
|
||||
{
|
||||
return index.column_opclasses.to_vec();
|
||||
}
|
||||
let original = match &index.original {
|
||||
Some(original) if !original.column_opclasses.is_empty() => original,
|
||||
_ => return vec![None; columns.len()],
|
||||
};
|
||||
let mut consumed = vec![false; original.columns.len()];
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let claimed = original
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, original_column)| !consumed[*i] && *original_column == column);
|
||||
match claimed {
|
||||
Some((i, _)) => {
|
||||
consumed[i] = true;
|
||||
original.column_opclasses.get(i).cloned().flatten()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_drop_index_sql(
|
||||
database_type: Option<DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
schema: Option<&str>,
|
||||
index_name: &str,
|
||||
) -> String {
|
||||
if database_type == Some(DatabaseType::Iris) {
|
||||
return format!("DROP INDEX {} ON TABLE {table};", quote_ident(dialect, index_name));
|
||||
}
|
||||
if matches!(dialect, StructureDialect::Mysql | StructureDialect::SqlServer) {
|
||||
return format!("DROP INDEX {} ON {table};", quote_ident(dialect, index_name));
|
||||
}
|
||||
if matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::Informix
|
||||
| StructureDialect::Sqlite
|
||||
) && schema.is_some_and(|schema| !schema.trim().is_empty())
|
||||
{
|
||||
return format!("DROP INDEX {}.{};", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, index_name));
|
||||
}
|
||||
format!("DROP INDEX {};", quote_ident(dialect, index_name))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_create_index_statements(
|
||||
database_type: Option<DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
index: &EditableStructureIndex,
|
||||
warnings: &mut Vec<String>,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
or_replace: bool,
|
||||
concurrently_supported: bool,
|
||||
for_new_table: bool,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let capabilities = capabilities_for(database_type_for_dialect(dialect), None);
|
||||
let name = clean(&index.name);
|
||||
let columns: Vec<String> =
|
||||
index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect();
|
||||
if name.is_empty() || columns.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// `CREATE INDEX CONCURRENTLY` is PostgreSQL-only: the flag is honored only when
|
||||
// the caller's real database type advertises the capability, so a stale or
|
||||
// forged `concurrently: true` from another engine is ignored and the original
|
||||
// SQL is generated unchanged. Unsupported concurrent requests (existing index,
|
||||
// partitioned parent) are rejected up front by `validate_concurrent_index_scope`
|
||||
// before any statement is built — this function never downgrades them.
|
||||
let concurrently = index.concurrently && concurrently_supported && dialect == StructureDialect::Postgres;
|
||||
|
||||
let unique = if index.is_unique { "UNIQUE " } else { "" };
|
||||
let replace = if or_replace { "OR REPLACE " } else { "" };
|
||||
let key_is_expression = key_expression_flags(index, &columns);
|
||||
let key_opclasses = key_opclasses(index, &columns);
|
||||
let cols = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, column)| {
|
||||
if dialect == StructureDialect::Mysql {
|
||||
mysql_index_column_sql(column)
|
||||
} else if dialect == StructureDialect::GaussdbM {
|
||||
gaussdbm_index_column_sql(column, key_is_expression[i])
|
||||
} else if dialect == StructureDialect::Postgres {
|
||||
postgres_index_column_sql(column, key_is_expression[i], key_opclasses[i].as_deref())
|
||||
} else if for_new_table {
|
||||
quote_new_ident(database_type, dialect, column)
|
||||
} else {
|
||||
quote_ident(dialect, column)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let idx_type = normalized_index_type(index);
|
||||
let mut type_prefix = String::new();
|
||||
let mut using_clause = String::new();
|
||||
|
||||
if !idx_type.is_empty() && capabilities.index_type {
|
||||
match dialect {
|
||||
StructureDialect::Postgres => using_clause = format!(" USING {idx_type}"),
|
||||
StructureDialect::SqlServer => type_prefix = format!("{idx_type} "),
|
||||
StructureDialect::Mysql => {
|
||||
let (prefix, using) = mysql_index_parts(&idx_type);
|
||||
type_prefix = prefix;
|
||||
using_clause = using;
|
||||
}
|
||||
StructureDialect::Oracle | StructureDialect::Dameng if idx_type == "BITMAP" => {
|
||||
type_prefix = "BITMAP ".to_string()
|
||||
}
|
||||
StructureDialect::GaussdbM => {
|
||||
let (prefix, using) = gaussdbm_index_parts(&idx_type);
|
||||
type_prefix = prefix;
|
||||
using_clause = using;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let included_columns: Vec<String> =
|
||||
index.included_columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect();
|
||||
let include_clause = if !included_columns.is_empty()
|
||||
&& capabilities.index_include
|
||||
&& matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer)
|
||||
{
|
||||
format!(
|
||||
" INCLUDE ({})",
|
||||
included_columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if for_new_table {
|
||||
quote_new_ident(database_type, dialect, column)
|
||||
} else {
|
||||
quote_ident(dialect, column)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let comment = clean(&index.comment);
|
||||
let comment_clause = if !comment.is_empty()
|
||||
&& capabilities.index_comment
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM)
|
||||
{
|
||||
format!(" COMMENT {}", quote_string(&comment))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let filter = clean(&index.filter);
|
||||
let supports_where = capabilities.index_filter
|
||||
&& matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer | StructureDialect::Sqlite);
|
||||
let where_clause = if !filter.is_empty() && supports_where { format!(" WHERE {filter}") } else { String::new() };
|
||||
let quoted_index_name =
|
||||
if for_new_table { quote_new_ident(database_type, dialect, &name) } else { quote_ident(dialect, &name) };
|
||||
let index_name = if dialect == StructureDialect::Sqlite && schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quoted_index_name)
|
||||
} else {
|
||||
quoted_index_name
|
||||
};
|
||||
// SQLite assigns an index to the database named by the qualified index.
|
||||
// Its CREATE INDEX grammar does not allow a qualified table name.
|
||||
let create_table =
|
||||
if dialect == StructureDialect::Sqlite { quote_ident(dialect, table_name) } else { table.to_string() };
|
||||
let create_sql = if dialect == StructureDialect::Postgres {
|
||||
let concurrent_clause = if concurrently { "CONCURRENTLY " } else { "" };
|
||||
format!(
|
||||
"CREATE {replace}{unique}INDEX {concurrent_clause}{index_name} ON {create_table}{using_clause} ({cols}){include_clause}{where_clause};"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"CREATE {replace}{unique}{type_prefix}INDEX {index_name}{using_clause} ON {create_table} ({cols}){include_clause}{where_clause}{comment_clause};"
|
||||
)
|
||||
};
|
||||
let mut statements = vec![create_sql];
|
||||
|
||||
if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::Postgres {
|
||||
statements.push(format!("COMMENT ON INDEX {} IS {};", quote_ident(dialect, &name), quote_string(&comment)));
|
||||
} else if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::SqlServer {
|
||||
statements.extend(build_sqlserver_index_comment_sql_for_profile(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
&name,
|
||||
&comment,
|
||||
driver_profile,
|
||||
));
|
||||
} else if !comment.is_empty()
|
||||
&& capabilities.index_comment
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM)
|
||||
{
|
||||
// Comment is embedded inline in the CREATE INDEX statement above
|
||||
} else if !comment.is_empty() && capabilities.index_comment {
|
||||
warnings.push(format!("Index comments are not supported for {} from this editor.", dialect_label(dialect)));
|
||||
}
|
||||
statements
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::dialect::StructureDialect;
|
||||
use super::types::TableStructureSqlOptions;
|
||||
use super::util::qualified_table;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub(super) fn validate_mysql_engine(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let Some(engine) = options.mysql_engine.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let engine = engine.trim();
|
||||
if options.database_type != Some(DatabaseType::Mysql) || options.is_gaussdb_m_mode {
|
||||
return vec!["Changing the table engine is supported only for native MySQL connections.".to_string()];
|
||||
}
|
||||
if engine.is_empty() {
|
||||
return vec!["MySQL table engine is required.".to_string()];
|
||||
}
|
||||
if engine.len() > 64 || !engine.chars().all(|character| character.is_ascii_alphanumeric() || character == '_') {
|
||||
return vec!["MySQL table engine contains invalid characters.".to_string()];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub(super) fn build_mysql_engine_change_sql(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let Some(engine) = options.mysql_engine.as_deref().map(str::trim).filter(|engine| !engine.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let table = qualified_table(StructureDialect::Mysql, options.schema.as_deref(), &options.table_name);
|
||||
vec![format!("ALTER TABLE {table} ENGINE = {engine};")]
|
||||
}
|
||||
|
||||
pub(super) fn append_mysql_table_option(statement: &mut String, option: &str) {
|
||||
if statement.ends_with(';') {
|
||||
statement.pop();
|
||||
}
|
||||
statement.push(' ');
|
||||
statement.push_str(option);
|
||||
statement.push(';');
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use super::dialect::StructureDialect;
|
||||
use super::types::{TableOwnerChangeSqlOptions, TableStructureSqlResult};
|
||||
use super::util::{qualified_table, quote_ident};
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub fn build_table_owner_change_sql(options: TableOwnerChangeSqlOptions) -> TableStructureSqlResult {
|
||||
let owner = &options.owner;
|
||||
if owner == &options.original_owner {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings: Vec::new() };
|
||||
}
|
||||
if owner.is_empty() {
|
||||
return TableStructureSqlResult {
|
||||
statements: Vec::new(),
|
||||
warnings: vec!["Table owner cannot be empty.".to_string()],
|
||||
};
|
||||
}
|
||||
if options.database_type != Some(DatabaseType::Postgres) {
|
||||
return TableStructureSqlResult {
|
||||
statements: Vec::new(),
|
||||
warnings: vec!["Changing the table owner is currently supported only for PostgreSQL.".to_string()],
|
||||
};
|
||||
}
|
||||
|
||||
let table = qualified_table(StructureDialect::Postgres, options.schema.as_deref(), &options.table_name);
|
||||
let owner = quote_ident(StructureDialect::Postgres, owner);
|
||||
TableStructureSqlResult { statements: vec![format!("ALTER TABLE {table} OWNER TO {owner};")], warnings: Vec::new() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn options(owner: &str, original_owner: &str) -> TableOwnerChangeSqlOptions {
|
||||
TableOwnerChangeSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("app schema".to_string()),
|
||||
table_name: "order\"items".to_string(),
|
||||
owner: owner.to_string(),
|
||||
original_owner: original_owner.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_quoted_postgres_owner_change() {
|
||||
let result = build_table_owner_change_sql(options("reporting\"role", "app_user"));
|
||||
assert!(result.warnings.is_empty());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["ALTER TABLE \"app schema\".\"order\"\"items\" OWNER TO \"reporting\"\"role\";".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_whitespace_in_postgres_role_names() {
|
||||
let changed = build_table_owner_change_sql(options(" app_user ", "app_user"));
|
||||
assert!(changed.warnings.is_empty());
|
||||
assert_eq!(
|
||||
changed.statements,
|
||||
vec!["ALTER TABLE \"app schema\".\"order\"\"items\" OWNER TO \" app_user \";".to_string()]
|
||||
);
|
||||
|
||||
let unchanged = build_table_owner_change_sql(options(" app_user ", " app_user "));
|
||||
assert!(unchanged.statements.is_empty());
|
||||
assert!(unchanged.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_unsupported_owner_changes() {
|
||||
let empty = build_table_owner_change_sql(options("", "app_user"));
|
||||
assert!(empty.statements.is_empty());
|
||||
assert_eq!(empty.warnings, vec!["Table owner cannot be empty."]);
|
||||
|
||||
let mut unsupported = options("reporting", "app_user");
|
||||
unsupported.database_type = Some(DatabaseType::Mysql);
|
||||
let unsupported = build_table_owner_change_sql(unsupported);
|
||||
assert!(unsupported.statements.is_empty());
|
||||
assert_eq!(unsupported.warnings, vec!["Changing the table owner is currently supported only for PostgreSQL."]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
||||
use super::dialect::{database_label, StructureDialect};
|
||||
use super::types::{EditableStructureTrigger, TableStructureSqlOptions, TriggerInfo};
|
||||
use super::util::{clean, qualified_new_table, qualified_table, quote_ident, quote_new_ident};
|
||||
|
||||
pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
build_trigger_sql_with_mode(options, warnings, false)
|
||||
}
|
||||
|
||||
pub(super) fn build_trigger_sql_for_new_table(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
build_trigger_sql_with_mode(options, warnings, true)
|
||||
}
|
||||
|
||||
fn build_trigger_sql_with_mode(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Vec<String> {
|
||||
if options.triggers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let dialect = super::dialect::capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect;
|
||||
let database_label = database_label(options.database_type);
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle | StructureDialect::SqlServer) {
|
||||
if options.triggers.iter().any(has_trigger_edit) {
|
||||
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let table = if for_new_table {
|
||||
qualified_new_table(options.database_type, dialect, options.schema.as_deref(), &options.table_name)
|
||||
} else {
|
||||
qualified_table(dialect, options.schema.as_deref(), &options.table_name)
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for trigger in &options.triggers {
|
||||
if trigger.marked_for_drop {
|
||||
if let Some(original) = &trigger.original {
|
||||
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(original) = &trigger.original {
|
||||
if dialect == StructureDialect::Oracle {
|
||||
if has_trigger_change(trigger, original) {
|
||||
warnings.push(format!(
|
||||
"Editing existing Oracle trigger \"{}\" requires its complete source definition.",
|
||||
original.name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_trigger_change(trigger, original) {
|
||||
continue;
|
||||
}
|
||||
// Oracle can replace a trigger in place; renames still require dropping the old object.
|
||||
if dialect != StructureDialect::Oracle || clean(&trigger.name) != clean(&original.name) {
|
||||
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sql) = create_trigger_sql(
|
||||
options.database_type,
|
||||
dialect,
|
||||
options.schema.as_deref(),
|
||||
&table,
|
||||
trigger,
|
||||
warnings,
|
||||
for_new_table,
|
||||
) {
|
||||
statements.push(sql);
|
||||
// SQL Server rebuilds via DROP + CREATE, which resets is_disabled to
|
||||
// enabled; restore the catalog-reported disabled state explicitly.
|
||||
// `table` is already qualified the same way the CREATE's ON clause is.
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& trigger.original.as_ref().is_some_and(|original| original.enabled == Some(false))
|
||||
{
|
||||
let schema = options.schema.as_deref();
|
||||
let trigger_name = qualified_trigger_object_name(dialect, schema, &trigger.name);
|
||||
statements.push(format!("DISABLE TRIGGER {trigger_name} ON {table};"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn qualified_trigger_object_name(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
|
||||
if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
|
||||
} else {
|
||||
quote_ident(dialect, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_trigger_edit(trigger: &EditableStructureTrigger) -> bool {
|
||||
trigger.marked_for_drop || trigger.original.as_ref().is_none_or(|original| has_trigger_change(trigger, original))
|
||||
}
|
||||
|
||||
fn has_trigger_change(trigger: &EditableStructureTrigger, original: &TriggerInfo) -> bool {
|
||||
clean(&trigger.name) != clean(&original.name)
|
||||
|| normalize_keyword(&trigger.timing) != normalize_keyword(&original.timing)
|
||||
|| normalize_keyword(&trigger.event) != normalize_keyword(&original.event)
|
||||
|| normalize_statement(&trigger.statement) != normalize_statement(original.statement.as_deref().unwrap_or(""))
|
||||
}
|
||||
|
||||
fn drop_trigger_sql(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
|
||||
let qualified_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
|
||||
} else {
|
||||
quote_ident(dialect, name)
|
||||
};
|
||||
format!("DROP TRIGGER {qualified_name};")
|
||||
}
|
||||
|
||||
fn create_trigger_sql(
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
trigger: &EditableStructureTrigger,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Option<String> {
|
||||
let name = clean(&trigger.name);
|
||||
let timing = normalize_keyword(&trigger.timing);
|
||||
let event = clean(&trigger.event);
|
||||
let statement = clean(&trigger.statement);
|
||||
|
||||
if name.is_empty() || timing.is_empty() || event.is_empty() || statement.is_empty() {
|
||||
warnings.push("Trigger name, timing, event, and statement are required.".to_string());
|
||||
return None;
|
||||
}
|
||||
match dialect {
|
||||
StructureDialect::Mysql => create_mysql_trigger_sql(table, &name, &timing, &event, &statement, warnings),
|
||||
StructureDialect::SqlServer => {
|
||||
create_sqlserver_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
|
||||
}
|
||||
StructureDialect::Oracle => create_oracle_trigger_sql(
|
||||
database_type,
|
||||
schema,
|
||||
table,
|
||||
&name,
|
||||
&timing,
|
||||
&event,
|
||||
&statement,
|
||||
warnings,
|
||||
for_new_table,
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_sqlserver_trigger_sql(
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
if !matches!(timing, "AFTER" | "INSTEAD OF") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
for item in event.split([',', '\n']) {
|
||||
let item = item.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_item = item.to_ascii_uppercase();
|
||||
for event in normalized_item.split(" OR ") {
|
||||
let event = event.trim().to_string();
|
||||
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger event \"{}\".", item));
|
||||
return None;
|
||||
}
|
||||
if !events.contains(&event) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
if events.is_empty() {
|
||||
warnings.push("SQL Server trigger event is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
|
||||
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_ident(StructureDialect::SqlServer, schema.unwrap()),
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
)
|
||||
} else {
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
};
|
||||
let body = sqlserver_trigger_body(statement);
|
||||
if body.is_empty() {
|
||||
warnings.push("SQL Server trigger statement is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"CREATE TRIGGER {trigger_name} ON {table} {timing} {} AS\n{};",
|
||||
events.join(", "),
|
||||
body.trim_end_matches(';').trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
fn sqlserver_trigger_body(statement: &str) -> &str {
|
||||
let statement = statement.trim();
|
||||
if statement.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("AS")) {
|
||||
return statement[2..].trim_start();
|
||||
}
|
||||
if !statement.to_ascii_uppercase().starts_with("CREATE TRIGGER") {
|
||||
return statement;
|
||||
}
|
||||
|
||||
// The body separator is the standalone AS that follows the event list
|
||||
// (`... AFTER INSERT, UPDATE AS <body>`); earlier AS tokens can appear in
|
||||
// trigger options such as `WITH EXECUTE AS OWNER`, so the first standalone
|
||||
// AS alone is not a reliable split point.
|
||||
let mut seen_event = false;
|
||||
let mut token = String::new();
|
||||
let mut char_indices = statement.char_indices().peekable();
|
||||
while let Some((index, ch)) = char_indices.next() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
if !char_indices.peek().is_some_and(|(_, next)| next.is_ascii_alphanumeric() || *next == '_') {
|
||||
if token.eq_ignore_ascii_case("AS") && seen_event {
|
||||
return statement[index + 1..].trim_start();
|
||||
}
|
||||
if matches!(token.to_ascii_uppercase().as_str(), "INSERT" | "UPDATE" | "DELETE" | "LOGON") {
|
||||
seen_event = true;
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
} else {
|
||||
token.clear();
|
||||
}
|
||||
}
|
||||
statement
|
||||
}
|
||||
|
||||
fn create_mysql_trigger_sql(
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let event = normalize_keyword(event);
|
||||
if !matches!(timing, "BEFORE" | "AFTER") {
|
||||
warnings.push(format!("Unsupported trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
}
|
||||
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
|
||||
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&event)));
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"CREATE TRIGGER {} {timing} {event} ON {table} FOR EACH ROW\n{};",
|
||||
quote_ident(StructureDialect::Mysql, name),
|
||||
statement.trim_end_matches(';').trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
fn create_oracle_trigger_sql(
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Option<String> {
|
||||
let Some((timing_clause, row_level)) = oracle_trigger_timing(timing) else {
|
||||
warnings.push(format!("Unsupported Oracle trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
};
|
||||
if !is_supported_oracle_trigger_event(event) {
|
||||
warnings.push(format!("Unsupported Oracle trigger event \"{}\".", clean(event)));
|
||||
return None;
|
||||
}
|
||||
|
||||
let trigger_identifier = if for_new_table {
|
||||
quote_new_ident(database_type, StructureDialect::Oracle, name)
|
||||
} else {
|
||||
quote_ident(StructureDialect::Oracle, name)
|
||||
};
|
||||
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(StructureDialect::Oracle, schema.unwrap()), trigger_identifier)
|
||||
} else {
|
||||
trigger_identifier
|
||||
};
|
||||
let row_clause = if row_level { "\nFOR EACH ROW" } else { "" };
|
||||
let statement = statement.trim_end().trim_end_matches('/').trim_end().trim_end_matches(';').trim_end();
|
||||
|
||||
Some(format!(
|
||||
"CREATE OR REPLACE TRIGGER {trigger_name} {timing_clause} {event} ON {table}{row_clause}\n{statement};"
|
||||
))
|
||||
}
|
||||
|
||||
fn oracle_trigger_timing(timing: &str) -> Option<(&'static str, bool)> {
|
||||
match timing {
|
||||
"BEFORE" | "BEFORE EACH ROW" => Some(("BEFORE", true)),
|
||||
"AFTER" | "AFTER EACH ROW" => Some(("AFTER", true)),
|
||||
"INSTEAD OF" | "INSTEAD OF EACH ROW" => Some(("INSTEAD OF", true)),
|
||||
"BEFORE STATEMENT" => Some(("BEFORE", false)),
|
||||
"AFTER STATEMENT" => Some(("AFTER", false)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_oracle_trigger_event(event: &str) -> bool {
|
||||
if event.contains([';', '\n', '\r']) || event.contains("--") || event.contains("/*") {
|
||||
return false;
|
||||
}
|
||||
let upper = event.to_ascii_uppercase();
|
||||
let clauses: Vec<&str> = upper.split(" OR ").map(str::trim).collect();
|
||||
!clauses.is_empty()
|
||||
&& clauses.iter().all(|clause| {
|
||||
matches!(*clause, "INSERT" | "DELETE")
|
||||
|| *clause == "UPDATE"
|
||||
|| clause.strip_prefix("UPDATE OF ").is_some_and(|columns| !columns.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_keyword(value: &str) -> String {
|
||||
clean(value).to_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn normalize_statement(value: &str) -> String {
|
||||
clean(value).trim_end_matches(';').trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_trigger_body_splits_after_event_list_not_execute_as() {
|
||||
// The AS inside `WITH EXECUTE AS OWNER` must not be treated as the body separator.
|
||||
let source = "CREATE TRIGGER dbo.t ON dbo.tbl WITH EXECUTE AS OWNER AFTER INSERT AS PRINT 'x'";
|
||||
assert_eq!(sqlserver_trigger_body(source), "PRINT 'x'");
|
||||
let no_options = "CREATE TRIGGER dbo.t ON dbo.tbl AFTER UPDATE AS SELECT 1 AS a";
|
||||
assert_eq!(sqlserver_trigger_body(no_options), "SELECT 1 AS a");
|
||||
let leading_as = "AS SELECT 2";
|
||||
assert_eq!(sqlserver_trigger_body(leading_as), "SELECT 2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableStructureColumn {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
#[serde(default)]
|
||||
pub default_value: String,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
#[serde(default)]
|
||||
pub is_primary_key: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<ColumnExtra>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<ColumnInfo>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original_position: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
#[serde(default)]
|
||||
pub character_set: String,
|
||||
#[serde(default)]
|
||||
pub collation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColumnExtra {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_increment: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_update_current_timestamp: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identity: Option<ColumnIdentity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_indexed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_stored: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_attribute: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_secondary_index: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColumnIdentity {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub increment: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ColumnInfo {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
pub column_default: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_primary_key: bool,
|
||||
#[serde(default)]
|
||||
pub extra: Option<String>,
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub character_set: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub collation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableStructureIndex {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_unique: bool,
|
||||
#[serde(default)]
|
||||
pub is_primary: bool,
|
||||
#[serde(default)]
|
||||
pub filter: String,
|
||||
#[serde(default)]
|
||||
pub index_type: String,
|
||||
#[serde(default)]
|
||||
pub included_columns: Vec<String>,
|
||||
/// Parallel to `columns`: operator class for each key column (PostgreSQL).
|
||||
/// `None` means default operator class. The UI keeps this array in lockstep
|
||||
/// with `columns`; when empty, opclasses fall back to `original` matching.
|
||||
#[serde(default)]
|
||||
pub column_opclasses: Vec<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
#[serde(default)]
|
||||
pub concurrently: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<IndexInfo>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IndexInfo {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_unique: bool,
|
||||
#[serde(default)]
|
||||
pub is_primary: bool,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(default)]
|
||||
pub index_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub included_columns: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
/// Parallel to `columns`: `true` at index `i` means `columns[i]` is a raw expression
|
||||
/// (e.g. sourced from `pg_get_indexdef`), not a plain column name.
|
||||
#[serde(default)]
|
||||
pub key_is_expression: Vec<bool>,
|
||||
/// Parallel to `columns`: operator class name for each key column, if non-default.
|
||||
#[serde(default)]
|
||||
pub column_opclasses: Vec<Option<String>>,
|
||||
/// Round-tripped from `crate::types::IndexInfo`: `true` when the introspected index is
|
||||
/// the object behind a PRIMARY KEY / UNIQUE constraint. Dameng only accepts constraint
|
||||
/// level DDL for those (#7959); a standalone unique index keeps the index-level path.
|
||||
/// Defaults to `false`, so a payload from an older client (or a source that does not
|
||||
/// report it) behaves exactly as before.
|
||||
#[serde(default)]
|
||||
pub constraint_backed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableStructureForeignKey {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub column: String,
|
||||
#[serde(default)]
|
||||
pub ref_schema: String,
|
||||
#[serde(default)]
|
||||
pub ref_table: String,
|
||||
#[serde(default)]
|
||||
pub ref_column: String,
|
||||
#[serde(default)]
|
||||
pub on_update: String,
|
||||
#[serde(default)]
|
||||
pub on_delete: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<ForeignKeyInfo>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ForeignKeyInfo {
|
||||
pub name: String,
|
||||
pub column: String,
|
||||
#[serde(default)]
|
||||
pub ref_schema: Option<String>,
|
||||
pub ref_table: String,
|
||||
pub ref_column: String,
|
||||
#[serde(default)]
|
||||
pub on_update: Option<String>,
|
||||
#[serde(default)]
|
||||
pub on_delete: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableStructureTrigger {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub timing: String,
|
||||
#[serde(default)]
|
||||
pub event: String,
|
||||
#[serde(default)]
|
||||
pub statement: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<TriggerInfo>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TriggerInfo {
|
||||
pub name: String,
|
||||
pub event: String,
|
||||
pub timing: String,
|
||||
#[serde(default)]
|
||||
pub statement: Option<String>,
|
||||
/// Carries the catalog-reported enabled state so SQL Server edits can
|
||||
/// restore it after the DROP + CREATE rebuild (`DISABLE TRIGGER`).
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableStructureSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
/// Driver profile reported by the connection (e.g. `"gbase8s"`). GBase 8s
|
||||
/// is Informix-compatible rather than MySQL-compatible like the rest of
|
||||
/// the `Gbase` family, so this disambiguates which dialect to generate.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver_profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<EditableStructureColumn>,
|
||||
#[serde(default)]
|
||||
pub indexes: Vec<EditableStructureIndex>,
|
||||
#[serde(default)]
|
||||
pub foreign_keys: Vec<EditableStructureForeignKey>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<EditableStructureTrigger>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original_table_comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mysql_engine: Option<String>,
|
||||
/// MySQL only: the table's current default collation
|
||||
/// (`information_schema.TABLES.TABLE_COLLATION`). A column whose collation
|
||||
/// merely matches it inherits the table default, so its `CHARACTER SET` /
|
||||
/// `COLLATE` clauses are redundant and are dropped from the generated DDL.
|
||||
/// Introspection keeps reporting the column's real values, which is what
|
||||
/// the structure editor renders in its charset/collation pickers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_collation: Option<String>,
|
||||
/// Whether the target table is a partitioned parent table (PostgreSQL
|
||||
/// `relkind = 'p'`). PostgreSQL rejects `CREATE INDEX CONCURRENTLY` on
|
||||
/// partitioned parents, so the builder refuses such a request up front
|
||||
/// (`validate_concurrent_index_scope`) instead of emitting SQL the server
|
||||
/// will reject or downgrading to a blocking `CREATE INDEX`.
|
||||
#[serde(default)]
|
||||
pub partitioned: bool,
|
||||
/// When true, the connection is GaussDB M-mode which uses MySQL-compatible
|
||||
/// SQL dialect with backtick quoting. The structure editor maps this to
|
||||
/// `StructureDialect::Mysql` so that DDL is generated with MySQL syntax.
|
||||
#[serde(default)]
|
||||
pub is_gaussdb_m_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableStructureSqlResult {
|
||||
pub statements: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableOwnerChangeSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub owner: String,
|
||||
#[serde(default)]
|
||||
pub original_owner: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqliteTableStructurePreview {
|
||||
pub statements: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub schema_revision: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SingleColumnAlterSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver_profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
pub column: EditableStructureColumn,
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
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)", "中文"), "'中文'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::column_format::has_dameng_identity;
|
||||
use super::dialect::{capabilities_for, StructureDialect};
|
||||
use super::indexes::has_existing_index_change;
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::clean;
|
||||
|
||||
/// Hard errors for `CREATE INDEX CONCURRENTLY` requests the editor cannot
|
||||
/// honor safely. Unlike the advisory warnings in [`validate_draft`], these
|
||||
/// reject the whole save plan: the caller asked for a non-blocking build and
|
||||
/// the only way to honor that is the requested concurrent DDL, so falling back
|
||||
/// to blocking index DDL must never happen silently.
|
||||
///
|
||||
/// Only real PostgreSQL advertises `index_concurrent`, so stale/forged
|
||||
/// `concurrently` flags on other engines are ignored here (they cannot express
|
||||
/// a concurrent request in the first place).
|
||||
pub(super) fn validate_concurrent_index_scope(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
if !capabilities.index_concurrent || capabilities.dialect != StructureDialect::Postgres {
|
||||
return errors;
|
||||
}
|
||||
for index in options.indexes.iter().filter(|index| !index.marked_for_drop) {
|
||||
if !index.concurrently {
|
||||
continue;
|
||||
}
|
||||
if index.original.is_some() {
|
||||
errors.push(format!(
|
||||
"CREATE INDEX CONCURRENTLY is only supported for newly created indexes. Editing an existing index \"{}\" with Concurrent enabled is not supported.",
|
||||
index.name
|
||||
));
|
||||
} else if options.partitioned {
|
||||
errors.push(
|
||||
"CREATE INDEX CONCURRENTLY is not supported on PostgreSQL partitioned parent tables. Create indexes concurrently on individual partitions and attach them separately."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
pub fn validate_draft(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
||||
validate_columns(&active_columns, &mut warnings);
|
||||
validate_dameng_identity(options, &active_columns, &mut warnings);
|
||||
validate_gin_opclass_warnings(options, &mut warnings);
|
||||
for index in options
|
||||
.indexes
|
||||
.iter()
|
||||
.filter(|index| !index.marked_for_drop && (index.original.is_none() || has_existing_index_change(index)))
|
||||
{
|
||||
if clean(&index.name).is_empty() {
|
||||
warnings.push("Index name cannot be empty.".to_string());
|
||||
}
|
||||
if index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).count() == 0 {
|
||||
warnings.push(format!(
|
||||
"Index \"{}\" needs at least one column.",
|
||||
if index.name.is_empty() { "(new)" } else { &index.name }
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
pub(super) fn validate_dameng_identity(
|
||||
options: &TableStructureSqlOptions,
|
||||
columns: &[&EditableStructureColumn],
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
if capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect != StructureDialect::Dameng {
|
||||
return;
|
||||
}
|
||||
|
||||
let identity_columns: Vec<_> = columns.iter().filter(|column| has_dameng_identity(column)).collect();
|
||||
if identity_columns.len() > 1 {
|
||||
warnings.push("Dameng tables can have only one identity column.".to_string());
|
||||
}
|
||||
for column in identity_columns {
|
||||
if column.extra.as_ref().and_then(|extra| extra.identity.as_ref()).and_then(|identity| identity.increment)
|
||||
== Some(0)
|
||||
{
|
||||
warnings.push(format!("Dameng identity column \"{}\" increment cannot be 0.", column.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_columns(columns: &[&EditableStructureColumn], warnings: &mut Vec<String>) {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for column in columns {
|
||||
if clean(&column.name).is_empty() {
|
||||
warnings.push("Column name cannot be empty.".to_string());
|
||||
}
|
||||
if clean(&column.data_type).is_empty() {
|
||||
warnings.push(format!(
|
||||
"Column \"{}\" type cannot be empty.",
|
||||
if column.name.is_empty() { "(new)" } else { &column.name }
|
||||
));
|
||||
}
|
||||
let key = clean(&column.name).to_lowercase();
|
||||
if !key.is_empty() && !names.insert(key) {
|
||||
warnings.push(format!("Column \"{}\" is duplicated.", column.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL GIN indexes on varchar/text/character columns without an explicit
|
||||
/// operator class are a common trap: the default `array_ops` class is rarely what
|
||||
/// the user wants, and `gin_trgm_ops` (from `pg_trgm` extension) or similar
|
||||
/// classes are almost always needed. This is a warning, not an error, because
|
||||
/// the default class may be deliberate in some cases.
|
||||
fn validate_gin_opclass_warnings(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
if capabilities.dialect != StructureDialect::Postgres {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a lookup: column name (lowercased) → data type for active columns.
|
||||
let col_types: std::collections::HashMap<String, &str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|col| !col.marked_for_drop)
|
||||
.map(|col| (clean(&col.name).to_lowercase(), col.data_type.as_str()))
|
||||
.collect();
|
||||
|
||||
let text_like = |dt: &str| -> bool {
|
||||
let lower = dt.to_lowercase();
|
||||
lower == "text"
|
||||
|| lower.starts_with("character varying")
|
||||
|| lower.starts_with("varchar")
|
||||
|| lower.starts_with("character")
|
||||
|| lower == "char"
|
||||
};
|
||||
|
||||
for index in options.indexes.iter().filter(|idx| {
|
||||
!idx.marked_for_drop
|
||||
&& clean(&idx.index_type).eq_ignore_ascii_case("GIN")
|
||||
&& (idx.original.is_none() || has_existing_index_change(idx))
|
||||
}) {
|
||||
for (i, col) in index.columns.iter().enumerate() {
|
||||
let col_name = clean(col);
|
||||
if col_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Expression columns are not matched against table column types.
|
||||
let is_expr =
|
||||
index.original.as_ref().and_then(|orig| orig.key_is_expression.get(i).copied()).unwrap_or(false);
|
||||
if is_expr {
|
||||
continue;
|
||||
}
|
||||
// Check if this column has a text-like type.
|
||||
let Some(dt) = col_types.get(&col_name.to_lowercase()) else {
|
||||
continue;
|
||||
};
|
||||
if !text_like(dt) {
|
||||
continue;
|
||||
}
|
||||
// Check if opclass is explicitly provided.
|
||||
let has_opclass =
|
||||
index.column_opclasses.get(i).and_then(|o| o.as_deref()).map(|v| !clean(v).is_empty()).unwrap_or(false);
|
||||
if !has_opclass {
|
||||
warnings.push(format!(
|
||||
"GIN index \"{}\" on column \"{}\" (type {}) has no operator class specified. Consider adding an operator class, e.g. `gin_trgm_ops` (requires pg_trgm extension) for text search.",
|
||||
index.name, col_name, dt
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user