mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 02:40:11 +00:00
refactor(table-structure): split SQL builders
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
use super::column_format::{clickhouse_column_type, column_data_type, column_definition};
|
||||
use super::comments::build_sqlserver_column_comment_sql;
|
||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||
use super::types::{EditableStructureColumn, SingleColumnAlterSqlOptions, TableStructureSqlResult};
|
||||
use super::util::{
|
||||
clean, format_default_for_sql, normalize_default, original_comment, original_default, qualified_table, quote_ident,
|
||||
quote_string,
|
||||
};
|
||||
|
||||
pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> TableStructureSqlResult {
|
||||
let capabilities = capabilities_for(options.database_type);
|
||||
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 mut warnings = Vec::new();
|
||||
let mut statements = Vec::new();
|
||||
|
||||
if options.column.marked_for_drop {
|
||||
let Some(original) = &options.column.original else {
|
||||
warnings.push("No original column info available.".to_string());
|
||||
return TableStructureSqlResult { statements, warnings };
|
||||
};
|
||||
if !capabilities.drop_column {
|
||||
warnings.push(format!("Dropping columns is not supported for {database_label} from this editor."));
|
||||
return TableStructureSqlResult { statements, warnings };
|
||||
}
|
||||
if original.is_primary_key {
|
||||
warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name));
|
||||
return TableStructureSqlResult { statements, warnings };
|
||||
}
|
||||
statements.push(format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, &original.name)));
|
||||
return TableStructureSqlResult { statements, warnings };
|
||||
}
|
||||
|
||||
let Some(original) = &options.column.original else {
|
||||
warnings.push(
|
||||
"This column has no original state — ALTER statements are only available for existing columns.".to_string(),
|
||||
);
|
||||
return TableStructureSqlResult { statements, warnings };
|
||||
};
|
||||
|
||||
if !has_existing_column_attribute_change(&options.column) && !has_column_extra_change(&options.column) {
|
||||
warnings.push("No changes detected for this column.".to_string());
|
||||
return TableStructureSqlResult { statements, warnings };
|
||||
}
|
||||
|
||||
let has_rename = options.column.name != original.name;
|
||||
let has_attribute_change = options.column.data_type.trim() != original.data_type.trim()
|
||||
|| options.column.is_nullable != original.is_nullable
|
||||
|| normalize_default(Some(&options.column.default_value)) != original_default(&options.column)
|
||||
|| clean(&options.column.comment) != original_comment(&options.column);
|
||||
|
||||
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_rename && !capabilities.rename_column)
|
||||
|| (has_attribute_change && !capabilities.alter_existing_column && dialect != StructureDialect::Sqlite)
|
||||
{
|
||||
return TableStructureSqlResult { statements, warnings };
|
||||
}
|
||||
|
||||
match dialect {
|
||||
StructureDialect::Mysql => statements.extend(build_mysql_existing_column_sql(&table, &options.column, "")),
|
||||
StructureDialect::Postgres => statements.extend(build_postgres_existing_column_sql(&table, &options.column)),
|
||||
StructureDialect::Oracle => {
|
||||
statements.extend(build_oracle_like_existing_column_sql(dialect, &table, &options.column))
|
||||
}
|
||||
StructureDialect::H2 => statements.extend(build_h2_existing_column_sql(&table, &options.column)),
|
||||
StructureDialect::ClickHouse => {
|
||||
statements.extend(build_clickhouse_existing_column_sql(&table, &options.column, ""))
|
||||
}
|
||||
StructureDialect::SqlServer => statements.extend(build_sqlserver_existing_column_sql(
|
||||
&table,
|
||||
&options.column,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
)),
|
||||
StructureDialect::Sqlite => {
|
||||
statements.extend(build_sqlite_existing_column_sql(&table, &options.column, &mut warnings))
|
||||
}
|
||||
_ => warnings.push(format!("Editing existing columns is not supported for {database_label} yet.")),
|
||||
}
|
||||
|
||||
TableStructureSqlResult { statements, warnings }
|
||||
}
|
||||
|
||||
pub(super) fn has_column_extra_change(column: &EditableStructureColumn) -> bool {
|
||||
let Some(original) = &column.original else { return false };
|
||||
let current_extra = column.extra.as_ref();
|
||||
match (current_extra, original.extra.as_deref()) {
|
||||
// Neither has extra → no change
|
||||
(None, None | Some("")) => false,
|
||||
// Extra added or removed
|
||||
(Some(_), None | Some("")) => true,
|
||||
(None, Some(_)) => true,
|
||||
// Both have extra → check auto_increment and on_update_current_timestamp flags
|
||||
(Some(curr), Some(orig)) => {
|
||||
let orig_lower = orig.to_lowercase();
|
||||
let curr_has_ai = curr.auto_increment.unwrap_or(false);
|
||||
let orig_has_ai = orig_lower.contains("auto_increment");
|
||||
let curr_has_on_update = curr.on_update_current_timestamp.unwrap_or(false);
|
||||
let orig_has_on_update = orig_lower.contains("on update");
|
||||
let curr_has_identity = curr.identity.is_some();
|
||||
// identity is harder to detect in free-form original.extra, so treat it as changed if present
|
||||
curr_has_ai != orig_has_ai || curr_has_on_update != orig_has_on_update || curr_has_identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_mysql_existing_column_sql(
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
position_clause: &str,
|
||||
) -> Vec<String> {
|
||||
let original_name = column.original.as_ref().map(|original| original.name.as_str()).unwrap_or(&column.name);
|
||||
let operation = if column.name == original_name {
|
||||
format!("MODIFY COLUMN {}", column_definition(StructureDialect::Mysql, column))
|
||||
} else {
|
||||
format!(
|
||||
"CHANGE COLUMN {} {}",
|
||||
quote_ident(StructureDialect::Mysql, original_name),
|
||||
column_definition(StructureDialect::Mysql, column)
|
||||
)
|
||||
};
|
||||
vec![format!("ALTER TABLE {table} {operation}{position_clause};")]
|
||||
}
|
||||
|
||||
pub(super) fn build_postgres_existing_column_sql(table: &str, column: &EditableStructureColumn) -> Vec<String> {
|
||||
let Some(original) = &column.original else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
let current_name = &column.name;
|
||||
if column.name != original.name {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} RENAME COLUMN {} TO {};",
|
||||
quote_ident(StructureDialect::Postgres, &original.name),
|
||||
quote_ident(StructureDialect::Postgres, &column.name)
|
||||
));
|
||||
}
|
||||
if column.data_type.trim() != original.data_type.trim() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} TYPE {};",
|
||||
quote_ident(StructureDialect::Postgres, current_name),
|
||||
column_data_type(StructureDialect::Postgres, column)
|
||||
));
|
||||
}
|
||||
if column.is_nullable != original.is_nullable {
|
||||
let action = if column.is_nullable { "DROP NOT NULL" } else { "SET NOT NULL" };
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} {action};",
|
||||
quote_ident(StructureDialect::Postgres, current_name)
|
||||
));
|
||||
}
|
||||
if normalize_default(Some(&column.default_value)) != original_default(column) {
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
let action = if default_value.is_empty() {
|
||||
"DROP DEFAULT".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"SET DEFAULT {}",
|
||||
format_default_for_sql(StructureDialect::Postgres, &column.data_type, &default_value)
|
||||
)
|
||||
};
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} {action};",
|
||||
quote_ident(StructureDialect::Postgres, current_name)
|
||||
));
|
||||
}
|
||||
if clean(&column.comment) != original_comment(column) {
|
||||
let comment_value =
|
||||
if clean(&column.comment).is_empty() { "NULL".to_string() } else { quote_string(&clean(&column.comment)) };
|
||||
statements.push(format!(
|
||||
"COMMENT ON COLUMN {table}.{} IS {comment_value};",
|
||||
quote_ident(StructureDialect::Postgres, current_name)
|
||||
));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_oracle_like_existing_column_sql(
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
) -> Vec<String> {
|
||||
let Some(original) = &column.original else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
let mut current_name = original.name.clone();
|
||||
if column.name != original.name {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} RENAME COLUMN {} TO {};",
|
||||
quote_ident(dialect, &original.name),
|
||||
quote_ident(dialect, &column.name)
|
||||
));
|
||||
current_name = column.name.clone();
|
||||
}
|
||||
let type_changed = column.data_type.trim() != original.data_type.trim();
|
||||
let nullable_changed = column.is_nullable != original.is_nullable;
|
||||
let default_changed = normalize_default(Some(&column.default_value)) != original_default(column);
|
||||
if type_changed || nullable_changed || default_changed {
|
||||
let data_type = column_data_type(dialect, column);
|
||||
let mut parts = vec![quote_ident(dialect, ¤t_name), data_type];
|
||||
// Always include nullability so the statement is self-contained (required by Dameng).
|
||||
if !column.is_nullable {
|
||||
parts.push("NOT NULL".to_string());
|
||||
} else {
|
||||
parts.push("NULL".to_string());
|
||||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() {
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
} else if default_changed {
|
||||
// User cleared the default — explicitly drop it.
|
||||
parts.push("DEFAULT NULL".to_string());
|
||||
}
|
||||
statements.push(format!("ALTER TABLE {table} MODIFY ({});", parts.join(" ")));
|
||||
}
|
||||
if clean(&column.comment) != original_comment(column) {
|
||||
let comment_value =
|
||||
if clean(&column.comment).is_empty() { "NULL".to_string() } else { quote_string(&clean(&column.comment)) };
|
||||
statements
|
||||
.push(format!("COMMENT ON COLUMN {table}.{} IS {comment_value};", quote_ident(dialect, ¤t_name)));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_existing_column_sql(
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
) -> Vec<String> {
|
||||
let Some(original) = &column.original else {
|
||||
return Vec::new();
|
||||
};
|
||||
let dialect = StructureDialect::SqlServer;
|
||||
let mut statements = Vec::new();
|
||||
let mut current_name = original.name.clone();
|
||||
|
||||
// Rename column via sp_rename
|
||||
if column.name != original.name {
|
||||
let full_obj_path = format!("{table}.{}", quote_ident(dialect, &original.name));
|
||||
statements.push(format!(
|
||||
"EXEC sp_rename '{full_obj_path}', '{new_name}', 'COLUMN';",
|
||||
full_obj_path = full_obj_path.replace('\'', "''"),
|
||||
new_name = column.name.replace('\'', "''")
|
||||
));
|
||||
current_name = column.name.clone();
|
||||
}
|
||||
|
||||
// Build the ALTER COLUMN clause for type + nullability changes
|
||||
if column.data_type.trim() != original.data_type.trim() || column.is_nullable != original.is_nullable {
|
||||
let null_clause = if column.is_nullable { "NULL" } else { "NOT NULL" };
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} {} {null_clause};",
|
||||
quote_ident(dialect, ¤t_name),
|
||||
column_data_type(dialect, column)
|
||||
));
|
||||
}
|
||||
|
||||
// Default value changes via ADD/DROP CONSTRAINT
|
||||
if normalize_default(Some(&column.default_value)) != original_default(column) {
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
let has_old_default = !column
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(|o| o.column_default.as_ref())
|
||||
.unwrap_or(&String::new())
|
||||
.trim()
|
||||
.is_empty()
|
||||
&& !column
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(|o| o.column_default.as_ref())
|
||||
.map(|d| d.trim().eq_ignore_ascii_case("null"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_old_default {
|
||||
statements.push(format!(
|
||||
"DECLARE @sql NVARCHAR(MAX);\
|
||||
SELECT @sql = 'ALTER TABLE {table} DROP CONSTRAINT [' + name + ']'\
|
||||
FROM sys.default_constraints\
|
||||
WHERE parent_object_id = OBJECT_ID('{table}') AND parent_column_id = COLUMNPROPERTY(OBJECT_ID('{table}'), '{col_name}', 'ColumnId');\
|
||||
EXEC sp_executesql @sql;",
|
||||
table = table.replace('\'', "''"),
|
||||
col_name = current_name.replace('\'', "''")
|
||||
));
|
||||
}
|
||||
if !default_value.is_empty() {
|
||||
let short_table =
|
||||
table.split('.').next_back().unwrap_or(table).trim_matches(|c: char| c == '[' || c == ']');
|
||||
let constraint_name = format!(
|
||||
"DF_{short_table}_{col_name}",
|
||||
short_table = short_table,
|
||||
col_name = current_name.trim_matches(|c: char| c == '[' || c == ']')
|
||||
);
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ADD CONSTRAINT [{constraint_name}] DEFAULT {} FOR {};",
|
||||
format_default_for_sql(StructureDialect::SqlServer, &column.data_type, &default_value),
|
||||
quote_ident(dialect, ¤t_name)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Column comment changes via extended properties
|
||||
if clean(&column.comment) != original_comment(column) {
|
||||
statements.extend(build_sqlserver_column_comment_sql(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
¤t_name,
|
||||
&column.comment,
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_h2_existing_column_sql(table: &str, column: &EditableStructureColumn) -> Vec<String> {
|
||||
let Some(original) = &column.original else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
let mut current_name = original.name.clone();
|
||||
if column.name != original.name {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} RENAME TO {};",
|
||||
quote_ident(StructureDialect::H2, &original.name),
|
||||
quote_ident(StructureDialect::H2, &column.name)
|
||||
));
|
||||
current_name = column.name.clone();
|
||||
}
|
||||
if column.data_type.trim() != original.data_type.trim() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} SET DATA TYPE {};",
|
||||
quote_ident(StructureDialect::H2, ¤t_name),
|
||||
column_data_type(StructureDialect::H2, column)
|
||||
));
|
||||
}
|
||||
if column.is_nullable != original.is_nullable {
|
||||
let action = if column.is_nullable { "DROP NOT NULL" } else { "SET NOT NULL" };
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} {action};",
|
||||
quote_ident(StructureDialect::H2, ¤t_name)
|
||||
));
|
||||
}
|
||||
if normalize_default(Some(&column.default_value)) != original_default(column) {
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
let action = if default_value.is_empty() {
|
||||
"DROP DEFAULT".to_string()
|
||||
} else {
|
||||
format!("SET DEFAULT {}", format_default_for_sql(StructureDialect::H2, &column.data_type, &default_value))
|
||||
};
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} {action};",
|
||||
quote_ident(StructureDialect::H2, ¤t_name)
|
||||
));
|
||||
}
|
||||
if clean(&column.comment) != original_comment(column) {
|
||||
let comment_value =
|
||||
if clean(&column.comment).is_empty() { "NULL".to_string() } else { quote_string(&clean(&column.comment)) };
|
||||
statements.push(format!(
|
||||
"COMMENT ON COLUMN {table}.{} IS {comment_value};",
|
||||
quote_ident(StructureDialect::H2, ¤t_name)
|
||||
));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_clickhouse_existing_column_sql(
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
position_clause: &str,
|
||||
) -> Vec<String> {
|
||||
let Some(original) = &column.original else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
let mut current_name = original.name.clone();
|
||||
if column.name != original.name {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} RENAME COLUMN {} TO {};",
|
||||
quote_ident(StructureDialect::ClickHouse, &original.name),
|
||||
quote_ident(StructureDialect::ClickHouse, &column.name)
|
||||
));
|
||||
current_name = column.name.clone();
|
||||
}
|
||||
let next_type = clickhouse_column_type(column);
|
||||
if next_type != original.data_type.trim()
|
||||
|| normalize_default(Some(&column.default_value)) != original_default(column)
|
||||
{
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() {
|
||||
let default_sql = format_default_for_sql(StructureDialect::ClickHouse, &column.data_type, &default_value);
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} MODIFY COLUMN {} {next_type} DEFAULT {default_sql}{position_clause};",
|
||||
quote_ident(StructureDialect::ClickHouse, ¤t_name)
|
||||
));
|
||||
} else if !original_default(column).is_empty() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} MODIFY COLUMN {} REMOVE DEFAULT;",
|
||||
quote_ident(StructureDialect::ClickHouse, ¤t_name)
|
||||
));
|
||||
if next_type != original.data_type.trim() || !position_clause.is_empty() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} MODIFY COLUMN {} {next_type}{position_clause};",
|
||||
quote_ident(StructureDialect::ClickHouse, ¤t_name)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} MODIFY COLUMN {} {next_type}{position_clause};",
|
||||
quote_ident(StructureDialect::ClickHouse, ¤t_name)
|
||||
));
|
||||
}
|
||||
} else if !position_clause.is_empty() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} MODIFY COLUMN {} {next_type}{position_clause};",
|
||||
quote_ident(StructureDialect::ClickHouse, ¤t_name)
|
||||
));
|
||||
}
|
||||
if clean(&column.comment) != original_comment(column) {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} COMMENT COLUMN {} {};",
|
||||
quote_ident(StructureDialect::ClickHouse, ¤t_name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlite_existing_column_sql(
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let Some(original) = &column.original else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
let unsupported_change = column.data_type.trim() != original.data_type.trim()
|
||||
|| column.is_nullable != original.is_nullable
|
||||
|| normalize_default(Some(&column.default_value)) != original_default(column)
|
||||
|| clean(&column.comment) != original_comment(column);
|
||||
if column.name != original.name {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} RENAME COLUMN {} TO {};",
|
||||
quote_ident(StructureDialect::Sqlite, &original.name),
|
||||
quote_ident(StructureDialect::Sqlite, &column.name)
|
||||
));
|
||||
}
|
||||
if unsupported_change {
|
||||
warnings.push(format!(
|
||||
"SQLite cannot safely alter existing column \"{}\" without rebuilding the table.",
|
||||
original.name
|
||||
));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn has_existing_column_attribute_change(column: &EditableStructureColumn) -> bool {
|
||||
let Some(original) = &column.original else {
|
||||
return false;
|
||||
};
|
||||
column.name != original.name
|
||||
|| column.data_type.trim() != original.data_type.trim()
|
||||
|| column.is_nullable != original.is_nullable
|
||||
|| normalize_default(Some(&column.default_value)) != original_default(column)
|
||||
|| clean(&column.comment) != original_comment(column)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use super::dialect::{is_oracle_like, StructureDialect};
|
||||
use super::types::EditableStructureColumn;
|
||||
use super::util::{clean, format_default_for_sql, normalize_default, quote_ident, quote_string};
|
||||
|
||||
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];
|
||||
if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse {
|
||||
parts.push("NOT 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() {
|
||||
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("ON UPDATE CURRENT_TIMESTAMP".to_string());
|
||||
}
|
||||
}
|
||||
if dialect == StructureDialect::Mysql && !clean(&column.comment).is_empty() {
|
||||
parts.push(format!("COMMENT {}", quote_string(&clean(&column.comment))));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn column_extra_clause(dialect: StructureDialect, column: &EditableStructureColumn) -> Option<String> {
|
||||
let extra = column.extra.as_ref()?;
|
||||
match dialect {
|
||||
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
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column_data_type(dialect: StructureDialect, column: &EditableStructureColumn) -> String {
|
||||
if dialect == StructureDialect::ClickHouse {
|
||||
return clickhouse_column_type(column);
|
||||
}
|
||||
normalize_column_data_type(dialect, &column.data_type)
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
|
||||
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 => {
|
||||
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 dialect == StructureDialect::Oracle { 9 } else { 6 };
|
||||
value <= max && params == value.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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use super::column_alter::{
|
||||
build_clickhouse_existing_column_sql, build_h2_existing_column_sql, build_mysql_existing_column_sql,
|
||||
build_oracle_like_existing_column_sql, build_postgres_existing_column_sql, build_sqlite_existing_column_sql,
|
||||
build_sqlserver_existing_column_sql, has_existing_column_attribute_change,
|
||||
};
|
||||
use super::column_format::column_definition;
|
||||
use super::comments::build_sqlserver_column_comment_sql;
|
||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::{
|
||||
clean, normalize_default, original_comment, original_default, qualified_table, quote_ident, quote_string,
|
||||
};
|
||||
|
||||
pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type);
|
||||
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();
|
||||
let has_original_column_positions = active_columns.iter().any(|column| column.original_position.is_some());
|
||||
let mut statements = Vec::new();
|
||||
|
||||
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;
|
||||
}
|
||||
statements.push(format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, &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 has_position_change = has_original_column_positions
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse)
|
||||
&& column.original.is_some()
|
||||
&& mysql_column_position_changed(&active_columns, active_index);
|
||||
|
||||
if column.original.is_none() {
|
||||
if !capabilities.add_column {
|
||||
warnings.push(format!("Adding columns is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
statements.extend(build_add_column_sql(
|
||||
dialect,
|
||||
&table,
|
||||
column,
|
||||
&position_clause,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if !has_existing_column_attribute_change(column) && !has_position_change {
|
||||
continue;
|
||||
}
|
||||
let original = column.original.as_ref().unwrap();
|
||||
let has_rename = column.name != original.name;
|
||||
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)
|
||||
|| clean(&column.comment) != original_comment(column);
|
||||
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;
|
||||
}
|
||||
|
||||
match dialect {
|
||||
StructureDialect::Mysql => statements.extend(build_mysql_existing_column_sql(
|
||||
&table,
|
||||
column,
|
||||
if has_position_change { &position_clause } else { "" },
|
||||
)),
|
||||
StructureDialect::Postgres => statements.extend(build_postgres_existing_column_sql(&table, column)),
|
||||
StructureDialect::Oracle => {
|
||||
statements.extend(build_oracle_like_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::SqlServer => statements.extend(build_sqlserver_existing_column_sql(
|
||||
&table,
|
||||
column,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
)),
|
||||
StructureDialect::Sqlite => statements.extend(build_sqlite_existing_column_sql(&table, column, warnings)),
|
||||
_ => warnings.push(format!("Editing existing columns is not supported for {database_label} yet.")),
|
||||
}
|
||||
}
|
||||
|
||||
// Emit primary key constraint changes after individual column changes
|
||||
statements.extend(build_primary_key_sql(options, dialect, &table, warnings));
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let old_pk_names: Vec<&str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| c.original.as_ref().is_some_and(|o| o.is_primary_key))
|
||||
.map(|c| c.name.as_str())
|
||||
.collect();
|
||||
|
||||
let new_pk_names: Vec<&str> =
|
||||
options.columns.iter().filter(|c| !c.marked_for_drop && c.is_primary_key).map(|c| c.name.as_str()).collect();
|
||||
|
||||
if old_pk_names == new_pk_names {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if !capabilities.alter_primary_key {
|
||||
warnings.push(format!(
|
||||
"Changing primary keys is not supported for {} from this editor.",
|
||||
database_label(options.database_type)
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut statements = Vec::new();
|
||||
|
||||
if !old_pk_names.is_empty() {
|
||||
match dialect {
|
||||
StructureDialect::Postgres => {
|
||||
let raw_table = options.table_name.split('.').next_back().unwrap_or(&options.table_name);
|
||||
let pk_name = format!("{}_pkey", clean(raw_table));
|
||||
statements.push(format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, &pk_name)));
|
||||
}
|
||||
StructureDialect::Mysql => {
|
||||
statements.push(format!("ALTER TABLE {table} DROP PRIMARY KEY;"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !new_pk_names.is_empty() {
|
||||
let pk_list = new_pk_names.iter().map(|n| quote_ident(dialect, n)).collect::<Vec<_>>().join(", ");
|
||||
statements.push(format!("ALTER TABLE {table} ADD PRIMARY KEY ({pk_list});"));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_add_column_sql(
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
position_clause: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
) -> Vec<String> {
|
||||
let add_keyword = if dialect == StructureDialect::SqlServer { "ADD" } else { "ADD COLUMN" };
|
||||
let definition = column_definition(dialect, column);
|
||||
let mut statements = if dialect == StructureDialect::Oracle {
|
||||
vec![format!("ALTER TABLE {table} ADD ({definition});")]
|
||||
} else {
|
||||
vec![format!("ALTER TABLE {table} {add_keyword} {definition}{position_clause};")]
|
||||
};
|
||||
if matches!(dialect, StructureDialect::Postgres | StructureDialect::Oracle) && !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(table, schema, table_name, &column.name, &column.comment));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
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 mysql_column_position_changed(columns: &[&EditableStructureColumn], index: usize) -> bool {
|
||||
let Some(column) = columns.get(index) else {
|
||||
return false;
|
||||
};
|
||||
if column.original.is_none() || column.original_position.is_none() {
|
||||
return false;
|
||||
}
|
||||
current_previous_original_column_name(columns, index) != original_previous_column_name(columns, column)
|
||||
}
|
||||
|
||||
pub(super) fn original_previous_column_name(
|
||||
columns: &[&EditableStructureColumn],
|
||||
column: &EditableStructureColumn,
|
||||
) -> Option<String> {
|
||||
let mut original_columns: Vec<_> =
|
||||
columns.iter().filter(|item| item.original.is_some() && item.original_position.is_some()).copied().collect();
|
||||
original_columns.sort_by_key(|item| item.original_position.unwrap_or(0));
|
||||
let index = original_columns.iter().position(|item| item.id == column.id)?;
|
||||
if index == 0 {
|
||||
None
|
||||
} else {
|
||||
original_columns[index - 1].original.as_ref().map(|original| original.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn current_previous_original_column_name(
|
||||
columns: &[&EditableStructureColumn],
|
||||
index: usize,
|
||||
) -> Option<String> {
|
||||
if index == 0 {
|
||||
None
|
||||
} else {
|
||||
columns.get(index - 1).map(|column| {
|
||||
column.original.as_ref().map(|original| original.name.clone()).unwrap_or_else(|| column.name.clone())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use super::dialect::{capabilities_for, 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);
|
||||
if !capabilities.comment {
|
||||
return Vec::new();
|
||||
}
|
||||
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();
|
||||
}
|
||||
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 => {
|
||||
vec![format!("ALTER TABLE {table} COMMENT = {quoted};")]
|
||||
}
|
||||
StructureDialect::Postgres | StructureDialect::Oracle | 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(&table, options.schema.as_deref(), &options.table_name, new_comment)
|
||||
}
|
||||
_ => {
|
||||
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())
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_table_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
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('\'', "''");
|
||||
|
||||
statements.push(format!(
|
||||
"IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = 0 AND name = N'MS_Description') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}';"
|
||||
));
|
||||
|
||||
if !clean(new_comment).is_empty() {
|
||||
let quoted_comment = clean(new_comment).replace('\'', "''");
|
||||
statements.push(format!(
|
||||
"EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}';"
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_index_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
index_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
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('\'', "''");
|
||||
|
||||
statements.push(format!(
|
||||
"IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = 0 AND name = N'MS_Description' AND class_desc = 'INDEX') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}';"
|
||||
));
|
||||
|
||||
if !clean(new_comment).is_empty() {
|
||||
let quoted_comment = clean(new_comment).replace('\'', "''");
|
||||
statements.push(format!(
|
||||
"EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}';"
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_column_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
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('\'', "''");
|
||||
|
||||
statements.push(format!(
|
||||
"IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND name = N'MS_Description') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}';"
|
||||
));
|
||||
|
||||
if !clean(new_comment).is_empty() {
|
||||
let quoted_comment = clean(new_comment).replace('\'', "''");
|
||||
statements.push(format!(
|
||||
"EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}';"
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use super::column_format::{column_data_type, column_extra_clause};
|
||||
use super::comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
|
||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||
use super::indexes::build_create_index_statements;
|
||||
use super::types::{TableStructureSqlOptions, TableStructureSqlResult};
|
||||
use super::util::{clean, format_default_for_sql, normalize_default, qualified_table, quote_ident, quote_string};
|
||||
use super::validation::validate_columns;
|
||||
|
||||
pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructureSqlResult {
|
||||
let mut warnings = Vec::new();
|
||||
if clean(&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);
|
||||
if !warnings.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
}
|
||||
|
||||
let capabilities = capabilities_for(options.database_type);
|
||||
let dialect = capabilities.dialect;
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let mut statements = Vec::new();
|
||||
let mut column_definitions = Vec::new();
|
||||
|
||||
for column in &active_columns {
|
||||
let data_type = column_data_type(dialect, column);
|
||||
let mut parts = vec![quote_ident(dialect, &column.name), data_type];
|
||||
if !column.is_nullable && !column.is_primary_key && dialect != StructureDialect::ClickHouse {
|
||||
parts.push("NOT 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() {
|
||||
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("ON UPDATE CURRENT_TIMESTAMP".to_string());
|
||||
}
|
||||
}
|
||||
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).collect();
|
||||
if !pk_columns.is_empty() {
|
||||
let pk_list = pk_columns.iter().map(|column| quote_ident(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 capabilities.comment {
|
||||
let table_comment = clean(options.table_comment.as_deref().unwrap_or(""));
|
||||
if !table_comment.is_empty() {
|
||||
if dialect == StructureDialect::Mysql {
|
||||
if let Some(last) = statements.last_mut() {
|
||||
*last = last.replace(");", &format!(") COMMENT = {};", quote_string(&table_comment)));
|
||||
}
|
||||
} else if matches!(dialect, StructureDialect::Postgres | StructureDialect::Oracle | 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(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&table_comment,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.comment
|
||||
&& matches!(dialect, StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::H2)
|
||||
{
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.push(format!(
|
||||
"COMMENT ON COLUMN {table}.{} IS {};",
|
||||
quote_ident(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_ident(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(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&column.name,
|
||||
&column.comment,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
&mut warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
));
|
||||
}
|
||||
|
||||
TableStructureSqlResult { statements, warnings }
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum StructureDialect {
|
||||
Mysql,
|
||||
Postgres,
|
||||
Sqlite,
|
||||
#[cfg(feature = "duckdb-bundled")]
|
||||
DuckDb,
|
||||
SqlServer,
|
||||
Oracle,
|
||||
H2,
|
||||
ClickHouse,
|
||||
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) alter_primary_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,
|
||||
alter_primary_key: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn capabilities_for(database_type: Option<DatabaseType>) -> TableStructureCapabilities {
|
||||
let base = TableStructureCapabilities::default();
|
||||
match database_type {
|
||||
Some(
|
||||
DatabaseType::Mysql
|
||||
| DatabaseType::Doris
|
||||
| DatabaseType::StarRocks
|
||||
| DatabaseType::Goldendb
|
||||
| DatabaseType::Sundb
|
||||
| DatabaseType::Databend
|
||||
| DatabaseType::Gbase,
|
||||
) => 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,
|
||||
alter_primary_key: true,
|
||||
..base
|
||||
},
|
||||
Some(
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kwdb
|
||||
| DatabaseType::OpenGauss
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Vastbase
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Firebird,
|
||||
) => 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,
|
||||
alter_primary_key: true,
|
||||
..base
|
||||
},
|
||||
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-bundled")]
|
||||
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::Oracle
|
||||
| DatabaseType::Dameng
|
||||
| DatabaseType::OceanbaseOracle
|
||||
| DatabaseType::Iris
|
||||
| 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
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_oracle_like(dialect: StructureDialect) -> bool {
|
||||
dialect == StructureDialect::Oracle
|
||||
}
|
||||
|
||||
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::Postgres => "postgres",
|
||||
StructureDialect::Sqlite => "sqlite",
|
||||
#[cfg(feature = "duckdb-bundled")]
|
||||
StructureDialect::DuckDb => "duckdb",
|
||||
StructureDialect::SqlServer => "sqlserver",
|
||||
StructureDialect::Oracle => "oracle",
|
||||
StructureDialect::H2 => "h2",
|
||||
StructureDialect::ClickHouse => "clickhouse",
|
||||
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::Postgres => Some(DatabaseType::Postgres),
|
||||
StructureDialect::Sqlite => Some(DatabaseType::Sqlite),
|
||||
#[cfg(feature = "duckdb-bundled")]
|
||||
StructureDialect::DuckDb => Some(DatabaseType::DuckDb),
|
||||
StructureDialect::SqlServer => Some(DatabaseType::SqlServer),
|
||||
StructureDialect::Oracle => Some(DatabaseType::Oracle),
|
||||
StructureDialect::H2 => Some(DatabaseType::H2),
|
||||
StructureDialect::ClickHouse => Some(DatabaseType::ClickHouse),
|
||||
StructureDialect::Unsupported => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use super::comments::build_sqlserver_index_comment_sql;
|
||||
use super::dialect::{capabilities_for, database_label, database_type_for_dialect, dialect_label, StructureDialect};
|
||||
use super::types::{EditableStructureIndex, TableStructureSqlOptions};
|
||||
use super::util::{clean, qualified_table, quote_ident, quote_string};
|
||||
|
||||
pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type);
|
||||
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 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;
|
||||
}
|
||||
statements.push(build_drop_index_sql(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;
|
||||
}
|
||||
statements.push(build_drop_index_sql(dialect, &table, options.schema.as_deref(), &original.name));
|
||||
statements.extend(build_create_index_statements(
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
));
|
||||
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(
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) 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(""))
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_drop_index_sql(
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
schema: Option<&str>,
|
||||
index_name: &str,
|
||||
) -> String {
|
||||
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)
|
||||
&& 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))
|
||||
}
|
||||
|
||||
pub(super) fn build_create_index_statements(
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
index: &EditableStructureIndex,
|
||||
warnings: &mut Vec<String>,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
) -> Vec<String> {
|
||||
let capabilities = capabilities_for(database_type_for_dialect(dialect));
|
||||
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();
|
||||
}
|
||||
|
||||
let unique = if index.is_unique { "UNIQUE " } else { "" };
|
||||
let cols = columns.iter().map(|column| 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 if idx_type == "BITMAP" => type_prefix = "BITMAP ".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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| quote_ident(dialect, column)).collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
} 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 create_sql = if dialect == StructureDialect::Postgres {
|
||||
format!(
|
||||
"CREATE {unique}{type_prefix}INDEX {} ON {table}{using_clause} ({cols}){include_clause}{where_clause};",
|
||||
quote_ident(dialect, &name)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"CREATE {unique}{type_prefix}INDEX {}{using_clause} ON {table} ({cols}){include_clause}{where_clause};",
|
||||
quote_ident(dialect, &name)
|
||||
)
|
||||
};
|
||||
let mut statements = vec![create_sql];
|
||||
|
||||
let comment = clean(&index.comment);
|
||||
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(table, schema, table_name, &name, &comment));
|
||||
} 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,870 @@
|
||||
use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
fn column(name: &str) -> EditableStructureColumn {
|
||||
EditableStructureColumn {
|
||||
id: name.to_string(),
|
||||
name: name.to_string(),
|
||||
data_type: "varchar(255)".to_string(),
|
||||
is_nullable: true,
|
||||
default_value: String::new(),
|
||||
comment: String::new(),
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
original: None,
|
||||
original_position: None,
|
||||
marked_for_drop: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn index(name: &str, columns: &[&str]) -> EditableStructureIndex {
|
||||
EditableStructureIndex {
|
||||
id: name.to_string(),
|
||||
name: name.to_string(),
|
||||
columns: columns.iter().map(|column| column.to_string()).collect(),
|
||||
is_unique: false,
|
||||
is_primary: false,
|
||||
filter: String::new(),
|
||||
index_type: String::new(),
|
||||
included_columns: Vec::new(),
|
||||
comment: String::new(),
|
||||
original: None,
|
||||
marked_for_drop: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_column_and_index_changes() {
|
||||
let mut renamed = column("display_name");
|
||||
renamed.data_type = "varchar(120)".to_string();
|
||||
renamed.is_nullable = false;
|
||||
renamed.default_value = "'guest'".to_string();
|
||||
renamed.comment = "Shown name".to_string();
|
||||
renamed.original = Some(ColumnInfo {
|
||||
name: "name".to_string(),
|
||||
data_type: "varchar(80)".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: Some(String::new()),
|
||||
});
|
||||
let mut email = column("email");
|
||||
email.is_nullable = false;
|
||||
let mut old_index = index("idx_old", &["name"]);
|
||||
old_index.marked_for_drop = true;
|
||||
old_index.original = Some(IndexInfo {
|
||||
name: "idx_old".to_string(),
|
||||
columns: vec!["name".to_string()],
|
||||
is_unique: false,
|
||||
is_primary: false,
|
||||
filter: None,
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
});
|
||||
let mut email_index = index("uniq_users_email", &["email"]);
|
||||
email_index.is_unique = true;
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![renamed, email],
|
||||
indexes: vec![old_index, email_index],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"ALTER TABLE `users` CHANGE COLUMN `name` `display_name` varchar(120) NOT NULL DEFAULT 'guest' COMMENT 'Shown name';",
|
||||
"ALTER TABLE `users` ADD COLUMN `email` varchar(255) NOT NULL;",
|
||||
"DROP INDEX `idx_old` ON `users`;",
|
||||
"CREATE UNIQUE INDEX `uniq_users_email` ON `users` (`email`);",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_add_timestamp_column_drops_invalid_precision() {
|
||||
let mut created_at = column("created_at");
|
||||
created_at.data_type = "timestamp(255)".to_string();
|
||||
created_at.default_value = "CURRENT_TIMESTAMP".to_string();
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![created_at],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["ALTER TABLE `users` ADD COLUMN `created_at` timestamp DEFAULT CURRENT_TIMESTAMP;"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_add_timestamp_column_preserves_valid_precision() {
|
||||
let mut created_at = column("created_at");
|
||||
created_at.data_type = "timestamp(3)".to_string();
|
||||
created_at.default_value = "CURRENT_TIMESTAMP(3)".to_string();
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![created_at],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["ALTER TABLE `users` ADD COLUMN `created_at` timestamp(3) DEFAULT CURRENT_TIMESTAMP(3);"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_postgres_create_table_with_comments_and_index() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "integer".to_string();
|
||||
id.is_nullable = false;
|
||||
id.is_primary_key = true;
|
||||
let mut name = column("name");
|
||||
name.data_type = "text".to_string();
|
||||
name.comment = "Display name".to_string();
|
||||
let mut idx = index("idx_users_name", &["name"]);
|
||||
idx.index_type = "gin".to_string();
|
||||
idx.comment = "search".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id, name],
|
||||
indexes: vec![idx],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"CREATE TABLE \"public\".\"users\" (\n \"id\" integer,\n \"name\" text,\n PRIMARY KEY (\"id\")\n);",
|
||||
"COMMENT ON COLUMN \"public\".\"users\".\"name\" IS 'Display name';",
|
||||
"CREATE INDEX \"idx_users_name\" ON \"public\".\"users\" USING GIN (\"name\");",
|
||||
"COMMENT ON INDEX \"idx_users_name\" IS 'search';",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_for_sqlite_unsafe_column_changes() {
|
||||
let mut col = column("name");
|
||||
col.data_type = "text".to_string();
|
||||
col.original = Some(ColumnInfo {
|
||||
name: "name".to_string(),
|
||||
data_type: "varchar(80)".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Sqlite),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.statements, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.warnings,
|
||||
vec!["SQLite cannot safely alter existing column \"name\" without rebuilding the table."]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_rqlite_changes_with_sqlite_dialect() {
|
||||
let mut email = column("email");
|
||||
email.data_type = "text".to_string();
|
||||
email.is_nullable = false;
|
||||
let mut email_index = index("idx_users_email", &["email"]);
|
||||
email_index.filter = "email IS NOT NULL".to_string();
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Rqlite),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![email],
|
||||
indexes: vec![email_index],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"ALTER TABLE \"users\" ADD COLUMN \"email\" text NOT NULL;",
|
||||
"CREATE INDEX \"idx_users_email\" ON \"users\" (\"email\") WHERE email IS NOT NULL;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_column_reorder_statements() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "int".to_string();
|
||||
id.is_nullable = false;
|
||||
id.is_primary_key = true;
|
||||
id.original_position = Some(0);
|
||||
id.original = Some(ColumnInfo {
|
||||
name: "id".to_string(),
|
||||
data_type: "int".to_string(),
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: true,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let mut email = column("email");
|
||||
email.original_position = Some(2);
|
||||
email.original = Some(ColumnInfo {
|
||||
name: "email".to_string(),
|
||||
data_type: "varchar(255)".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let mut name = column("display_name");
|
||||
name.id = "name".to_string();
|
||||
name.data_type = "varchar(120)".to_string();
|
||||
name.original_position = Some(1);
|
||||
name.original = Some(ColumnInfo {
|
||||
name: "name".to_string(),
|
||||
data_type: "varchar(80)".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id, email, name],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"ALTER TABLE `users` MODIFY COLUMN `email` varchar(255) AFTER `id`;",
|
||||
"ALTER TABLE `users` CHANGE COLUMN `name` `display_name` varchar(120) AFTER `email`;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sql_server_quoted_column_and_index_statements() {
|
||||
let mut email = column("email");
|
||||
email.data_type = "nvarchar(255)".to_string();
|
||||
email.is_nullable = false;
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![email],
|
||||
indexes: vec![index("idx_users_email", &["email"])],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"ALTER TABLE [dbo].[users] ADD [email] nvarchar(255) NOT NULL;",
|
||||
"CREATE INDEX [idx_users_email] ON [dbo].[users] ([email]);",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "duckdb-bundled")]
|
||||
#[test]
|
||||
fn builds_duckdb_create_table_statements() {
|
||||
let mut name = column("name");
|
||||
name.data_type = "VARCHAR".to_string();
|
||||
name.is_nullable = false;
|
||||
let mut created_at = column("created_at");
|
||||
created_at.data_type = "TIMESTAMP".to_string();
|
||||
created_at.default_value = "current_timestamp".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::DuckDb),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![name, created_at],
|
||||
indexes: vec![index("idx_events_name", &["name"])],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"CREATE TABLE \"events\" (\n \"name\" VARCHAR NOT NULL,\n \"created_at\" TIMESTAMP DEFAULT current_timestamp\n);",
|
||||
"CREATE INDEX \"idx_events_name\" ON \"events\" (\"name\");",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_clickhouse_nullable_comment_and_reorder_statements() {
|
||||
let mut source = column("source");
|
||||
source.data_type = "String".to_string();
|
||||
source.is_nullable = true;
|
||||
source.comment = "traffic source".to_string();
|
||||
let mut status = column("status");
|
||||
status.data_type = "Nullable(String)".to_string();
|
||||
status.is_nullable = false;
|
||||
status.comment = "current status".to_string();
|
||||
status.original = Some(ColumnInfo {
|
||||
name: "status".to_string(),
|
||||
data_type: "Nullable(String)".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: Some("'pending'".to_string()),
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: Some("old status".to_string()),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::ClickHouse),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![source, status],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"ALTER TABLE \"events\" ADD COLUMN \"source\" Nullable(String);",
|
||||
"ALTER TABLE \"events\" COMMENT COLUMN \"source\" 'traffic source';",
|
||||
"ALTER TABLE \"events\" MODIFY COLUMN \"status\" REMOVE DEFAULT;",
|
||||
"ALTER TABLE \"events\" MODIFY COLUMN \"status\" String;",
|
||||
"ALTER TABLE \"events\" COMMENT COLUMN \"status\" 'current status';",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_h2_schema_qualified_existing_column_statements() {
|
||||
let mut name = column("DISPLAY_NAME");
|
||||
name.id = "name".to_string();
|
||||
name.data_type = "VARCHAR(120)".to_string();
|
||||
name.is_nullable = false;
|
||||
name.default_value = "'guest'".to_string();
|
||||
name.comment = "Display name".to_string();
|
||||
name.original = Some(ColumnInfo {
|
||||
name: "NAME".to_string(),
|
||||
data_type: "VARCHAR(80)".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: Some(String::new()),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::H2),
|
||||
schema: Some("PUBLIC".to_string()),
|
||||
table_name: "USERS".to_string(),
|
||||
columns: vec![name],
|
||||
indexes: vec![index("IDX_USERS_DISPLAY_NAME", &["DISPLAY_NAME"])],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"ALTER TABLE \"PUBLIC\".\"USERS\" ALTER COLUMN \"NAME\" RENAME TO \"DISPLAY_NAME\";",
|
||||
"ALTER TABLE \"PUBLIC\".\"USERS\" ALTER COLUMN \"DISPLAY_NAME\" SET DATA TYPE VARCHAR(120);",
|
||||
"ALTER TABLE \"PUBLIC\".\"USERS\" ALTER COLUMN \"DISPLAY_NAME\" SET NOT NULL;",
|
||||
"ALTER TABLE \"PUBLIC\".\"USERS\" ALTER COLUMN \"DISPLAY_NAME\" SET DEFAULT 'guest';",
|
||||
"COMMENT ON COLUMN \"PUBLIC\".\"USERS\".\"DISPLAY_NAME\" IS 'Display name';",
|
||||
"CREATE INDEX \"IDX_USERS_DISPLAY_NAME\" ON \"PUBLIC\".\"USERS\" (\"DISPLAY_NAME\");",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_postgres_alter_table_add_primary_key() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "integer".to_string();
|
||||
id.is_nullable = false;
|
||||
id.is_primary_key = true;
|
||||
id.original = Some(ColumnInfo {
|
||||
name: "id".to_string(),
|
||||
data_type: "integer".to_string(),
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(result.statements, vec!["ALTER TABLE \"public\".\"users\" ADD PRIMARY KEY (\"id\");"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_postgres_alter_table_drop_primary_key() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "integer".to_string();
|
||||
id.is_nullable = false;
|
||||
id.is_primary_key = false;
|
||||
id.original = Some(ColumnInfo {
|
||||
name: "id".to_string(),
|
||||
data_type: "integer".to_string(),
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: true,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(result.statements, vec!["ALTER TABLE \"public\".\"users\" DROP CONSTRAINT \"users_pkey\";"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_alter_table_change_primary_key() {
|
||||
let mut old_pk = column("id");
|
||||
old_pk.id = "old_id".to_string();
|
||||
old_pk.data_type = "int".to_string();
|
||||
old_pk.is_nullable = false;
|
||||
old_pk.is_primary_key = false;
|
||||
old_pk.original = Some(ColumnInfo {
|
||||
name: "id".to_string(),
|
||||
data_type: "int".to_string(),
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: true,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let mut new_pk = column("uuid");
|
||||
new_pk.id = "new_uuid".to_string();
|
||||
new_pk.data_type = "varchar(36)".to_string();
|
||||
new_pk.is_nullable = false;
|
||||
new_pk.is_primary_key = true;
|
||||
new_pk.original = Some(ColumnInfo {
|
||||
name: "uuid".to_string(),
|
||||
data_type: "varchar(36)".to_string(),
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![old_pk, new_pk],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["ALTER TABLE `users` DROP PRIMARY KEY;", "ALTER TABLE `users` ADD PRIMARY KEY (`uuid`);",]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_no_statements_when_primary_key_unchanged() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "integer".to_string();
|
||||
id.is_nullable = false;
|
||||
id.is_primary_key = true;
|
||||
id.original = Some(ColumnInfo {
|
||||
name: "id".to_string(),
|
||||
data_type: "integer".to_string(),
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: true,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_sqlite_cannot_alter_primary_key() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "integer".to_string();
|
||||
id.is_nullable = false;
|
||||
id.is_primary_key = true;
|
||||
id.original = Some(ColumnInfo {
|
||||
name: "id".to_string(),
|
||||
data_type: "integer".to_string(),
|
||||
is_nullable: false,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Sqlite),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.statements, Vec::<String>::new());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert!(result.warnings[0].contains("primary key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_create_table_with_auto_increment() {
|
||||
let mut col = column("id");
|
||||
col.data_type = "int".to_string();
|
||||
col.is_nullable = false;
|
||||
col.is_primary_key = true;
|
||||
col.extra = Some(ColumnExtra { auto_increment: Some(true), on_update_current_timestamp: None, identity: None });
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(result.statements.len(), 1);
|
||||
assert!(result.statements[0].contains("AUTO_INCREMENT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_create_table_with_on_update_current_timestamp() {
|
||||
let mut col = column("updated_at");
|
||||
col.data_type = "timestamp".to_string();
|
||||
col.is_nullable = false;
|
||||
col.default_value = "CURRENT_TIMESTAMP".to_string();
|
||||
col.extra = Some(ColumnExtra { auto_increment: None, on_update_current_timestamp: Some(true), identity: None });
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("ON UPDATE CURRENT_TIMESTAMP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_create_table_with_identity() {
|
||||
let mut col = column("id");
|
||||
col.data_type = "integer".to_string();
|
||||
col.is_nullable = false;
|
||||
col.extra = Some(ColumnExtra {
|
||||
auto_increment: None,
|
||||
on_update_current_timestamp: None,
|
||||
identity: Some(ColumnIdentity { generation: Some("BY DEFAULT".to_string()), seed: None, increment: None }),
|
||||
});
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("GENERATED BY DEFAULT AS IDENTITY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_create_table_with_identity() {
|
||||
let mut col = column("id");
|
||||
col.data_type = "int".to_string();
|
||||
col.is_nullable = false;
|
||||
col.extra = Some(ColumnExtra {
|
||||
auto_increment: Some(true),
|
||||
on_update_current_timestamp: None,
|
||||
identity: Some(ColumnIdentity { generation: None, seed: Some(100), increment: Some(5) }),
|
||||
});
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("IDENTITY(100, 5)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_quotes_datetime_literal_default() {
|
||||
let mut col = column("created_at");
|
||||
col.data_type = "datetime".to_string();
|
||||
col.default_value = "2024-01-01 00:00:00".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT '2024-01-01 00:00:00'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_does_not_quote_current_timestamp() {
|
||||
let mut col = column("updated_at");
|
||||
col.data_type = "timestamp".to_string();
|
||||
col.default_value = "CURRENT_TIMESTAMP".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT CURRENT_TIMESTAMP"));
|
||||
assert!(!result.statements[0].contains("DEFAULT 'CURRENT_TIMESTAMP'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_does_not_quote_temporal_function_with_parens() {
|
||||
let mut col = column("created_at");
|
||||
col.data_type = "datetime".to_string();
|
||||
col.default_value = "NOW()".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT NOW()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_date_literal_default_is_quoted() {
|
||||
let mut col = column("birth_date");
|
||||
col.data_type = "date".to_string();
|
||||
col.default_value = "2000-01-01".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT '2000-01-01'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_time_literal_default_is_quoted() {
|
||||
let mut col = column("start_time");
|
||||
col.data_type = "time".to_string();
|
||||
col.default_value = "09:00:00".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "shifts".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT '09:00:00'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_temporal_types_are_not_quoted() {
|
||||
let mut col = column("score");
|
||||
col.data_type = "int".to_string();
|
||||
col.default_value = "0".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "games".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT 0"));
|
||||
assert!(!result.statements[0].contains("DEFAULT '0'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_timestamp_literal_is_quoted() {
|
||||
let mut col = column("logged_at");
|
||||
col.data_type = "timestamp".to_string();
|
||||
col.default_value = "2024-06-01 12:00:00".to_string();
|
||||
col.original = Some(ColumnInfo {
|
||||
name: "logged_at".to_string(),
|
||||
data_type: "timestamp".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: Some(String::new()),
|
||||
});
|
||||
|
||||
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
column: col,
|
||||
});
|
||||
|
||||
assert!(result.statements.iter().any(|s| s.contains("SET DEFAULT '2024-06-01 12:00:00'")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_single_column_alter_quotes_datetime_literal() {
|
||||
let mut col = column("created_at");
|
||||
col.data_type = "datetime".to_string();
|
||||
col.default_value = "2024-01-01 00:00:00".to_string();
|
||||
col.original = Some(ColumnInfo {
|
||||
name: "created_at".to_string(),
|
||||
data_type: "datetime".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: Some(String::new()),
|
||||
});
|
||||
|
||||
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
column: col,
|
||||
});
|
||||
|
||||
assert!(result.statements.iter().any(|s| s.contains("DEFAULT '2024-01-01 00:00:00'")));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, 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>,
|
||||
}
|
||||
|
||||
#[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, 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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
#[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, 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>,
|
||||
}
|
||||
|
||||
#[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 SingleColumnAlterSqlOptions {
|
||||
#[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,
|
||||
pub column: EditableStructureColumn,
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::dialect::StructureDialect;
|
||||
use super::types::EditableStructureColumn;
|
||||
|
||||
pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, table_name: &str) -> String {
|
||||
if matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::SqlServer | StructureDialect::H2
|
||||
) && 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)
|
||||
}
|
||||
|
||||
pub(super) fn quote_ident(dialect: StructureDialect, name: &str) -> String {
|
||||
match dialect {
|
||||
StructureDialect::Mysql => format!("`{}`", name.replace('`', "``")),
|
||||
StructureDialect::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn quote_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
pub(super) fn clean(value: &str) -> String {
|
||||
value.trim().to_string()
|
||||
}
|
||||
|
||||
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 => 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 => 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")
|
||||
}
|
||||
_ => 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 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) && !is_temporal_expression(default_value) {
|
||||
quote_string(default_value)
|
||||
} else {
|
||||
default_value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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 {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn original_default(column: &EditableStructureColumn) -> String {
|
||||
normalize_default(column.original.as_ref().and_then(|original| original.column_default.as_ref()))
|
||||
}
|
||||
|
||||
pub(super) fn original_comment(column: &EditableStructureColumn) -> String {
|
||||
clean(column.original.as_ref().and_then(|original| original.comment.as_deref()).unwrap_or(""))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::indexes::has_existing_index_change;
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::clean;
|
||||
|
||||
pub(super) 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);
|
||||
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_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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user