feat(core): split foundational capabilities into dedicated crates

This commit is contained in:
t8y2 committed 2026-09-18 13:00:56 +08:00
1 parent d50f5175d6
commit bc00431834
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
));
}
}
}
}