diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue
index dca792aa3d..ad5c99dfe9 100644
--- a/apps/desktop/src/components/structure/TableStructureEditor.vue
+++ b/apps/desktop/src/components/structure/TableStructureEditor.vue
@@ -464,7 +464,7 @@ watch(
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index a95422a7db..60bf03cb1e 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -973,6 +973,7 @@ export default {
comment: "Comment",
editComment: "Edit comment",
commentPlaceholder: "Enter column comment...",
+ tableCommentPlaceholder: "Enter table comment...",
actions: "Actions",
indexName: "Index",
indexColumns: "Columns",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index ba05225419..15c936d18b 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -867,6 +867,7 @@ export default {
comment: "Comentario",
editComment: "Editar comentario",
commentPlaceholder: "Ingresar comentario de columna...",
+ tableCommentPlaceholder: "Ingresar comentario de tabla...",
actions: "Acciones",
indexName: "Índice",
indexColumns: "Columnas",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 091d5d7c62..62cf2a22a8 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -951,6 +951,7 @@ export default {
comment: "注释",
editComment: "编辑注释",
commentPlaceholder: "输入字段注释...",
+ tableCommentPlaceholder: "输入表注释...",
actions: "操作",
indexName: "索引名",
indexColumns: "字段列表",
diff --git a/apps/desktop/src/lib/tableStructureCapabilities.ts b/apps/desktop/src/lib/tableStructureCapabilities.ts
index 735a8f39b5..648c2b0c7a 100644
--- a/apps/desktop/src/lib/tableStructureCapabilities.ts
+++ b/apps/desktop/src/lib/tableStructureCapabilities.ts
@@ -139,12 +139,19 @@ const sqlserverCapabilities = capabilities({
createTable: true,
addColumn: true,
dropColumn: true,
+ renameColumn: true,
+ alterExistingColumn: true,
+ alterType: true,
+ alterNullability: true,
+ alterDefault: true,
+ comment: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
indexType: true,
indexInclude: true,
indexFilter: true,
+ indexComment: true,
});
const oracleCapabilities = capabilities({
diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs
index 82f609609d..d1f1dda5c7 100644
--- a/crates/dbx-core/src/db/sqlserver.rs
+++ b/crates/dbx-core/src/db/sqlserver.rs
@@ -293,16 +293,18 @@ pub async fn list_tables(
.filter(|value| !value.trim().is_empty())
.map(|value| format!(" AND o.name LIKE '%{}%' ESCAPE '\\' ", escape_like_literal(value.trim())))
.unwrap_or_default();
+ let schema_escaped = schema.replace('\'', "''");
let sql = format!(
- "SELECT {top}o.name, CASE WHEN o.type = 'V' THEN 'VIEW' ELSE 'BASE TABLE' END \
+ "SELECT {top}o.name, CASE WHEN o.type = 'V' THEN 'VIEW' ELSE 'BASE TABLE' END, \
+ ep.value AS TABLE_COMMENT \
FROM sys.objects o \
JOIN sys.schemas s ON s.schema_id = o.schema_id \
- WHERE s.name = '{}' \
+ OUTER APPLY (SELECT CAST(ep.value AS NVARCHAR(MAX)) AS value FROM sys.extended_properties ep WHERE ep.major_id = o.object_id AND ep.minor_id = 0 AND ep.name = N'MS_Description') ep \
+ WHERE s.name = '{schema_escaped}' \
AND o.type IN ('U','V') \
AND o.is_ms_shipped = 0 \
{filter_clause}\
- ORDER BY o.name",
- schema.replace('\'', "''")
+ ORDER BY o.name"
);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
@@ -311,7 +313,7 @@ pub async fn list_tables(
.map(|row| TableInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
table_type: row.get::<&str, _>(1).unwrap_or("BASE TABLE").to_string(),
- comment: None,
+ comment: row.get::<&str, _>(2).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
})
.collect())
}
@@ -330,7 +332,7 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
object_type: row.get::<&str, _>(1).unwrap_or("TABLE").to_string(),
schema: Some(schema.to_string()),
- comment: None,
+ comment: row.get::<&str, _>(4).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
created_at: row.get::(2).map(|value| value.to_string()),
updated_at: row.get::(3).map(|value| value.to_string()),
})
@@ -338,6 +340,7 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<
}
fn sqlserver_list_objects_sql(schema: &str) -> String {
+ let s = schema.replace('\'', "''");
format!(
"SELECT o.name, \
CASE o.type \
@@ -352,10 +355,12 @@ fn sqlserver_list_objects_sql(schema: &str) -> String {
ELSE o.type_desc \
END AS object_type, \
o.create_date, \
- o.modify_date \
+ o.modify_date, \
+ ep.value AS object_comment \
FROM sys.objects o \
JOIN sys.schemas s ON s.schema_id = o.schema_id \
- WHERE s.name = '{}' \
+ OUTER APPLY (SELECT CAST(ep.value AS NVARCHAR(MAX)) AS value FROM sys.extended_properties ep WHERE ep.major_id = o.object_id AND ep.minor_id = 0 AND ep.name = N'MS_Description') ep \
+ WHERE s.name = '{s}' \
AND o.type IN ('U','V','P','FN','IF','TF','FS','FT') \
AND o.is_ms_shipped = 0 \
ORDER BY CASE o.type \
@@ -363,8 +368,7 @@ fn sqlserver_list_objects_sql(schema: &str) -> String {
WHEN 'V' THEN 1 \
WHEN 'P' THEN 2 \
ELSE 3 \
- END, o.name",
- schema.replace('\'', "''")
+ END, o.name"
)
}
@@ -433,7 +437,7 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
is_primary_key: row.get::(4).unwrap_or(0) == 1,
extra: None,
- comment: None,
+ comment: row.get::<&str, _>(9).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
numeric_precision: num_prec,
numeric_scale: num_scale,
character_maximum_length: max_len,
@@ -443,17 +447,20 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str
}
fn sqlserver_columns_sql(schema: &str, table: &str) -> String {
+ let s = schema.replace('\'', "''");
+ let t = table.replace('\'', "''");
format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \
- c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, c.DATETIME_PRECISION \
+ c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, c.DATETIME_PRECISION, \
+ ep.value AS COLUMN_COMMENT \
FROM INFORMATION_SCHEMA.COLUMNS c \
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME \
AND kcu.CONSTRAINT_NAME IN (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_TYPE = 'PRIMARY KEY' AND TABLE_SCHEMA = '{s}' AND TABLE_NAME = '{t}') \
+ OUTER APPLY (SELECT CAST(ep.value AS NVARCHAR(MAX)) AS value FROM sys.extended_properties ep WHERE ep.major_id = OBJECT_ID(QUOTENAME('{s}') + '.' + QUOTENAME('{t}')) AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(QUOTENAME('{s}') + '.' + QUOTENAME('{t}')), c.COLUMN_NAME, 'ColumnId') AND ep.name = N'MS_Description') ep \
WHERE c.TABLE_SCHEMA = '{s}' AND c.TABLE_NAME = '{t}' \
- ORDER BY c.ORDINAL_POSITION",
- s = schema.replace('\'', "''"), t = table.replace('\'', "''")
+ ORDER BY c.ORDINAL_POSITION"
)
}
diff --git a/crates/dbx-core/src/table_structure_sql.rs b/crates/dbx-core/src/table_structure_sql.rs
index c584646a57..263f80312d 100644
--- a/crates/dbx-core/src/table_structure_sql.rs
+++ b/crates/dbx-core/src/table_structure_sql.rs
@@ -242,12 +242,16 @@ fn capabilities_for(database_type: Option) -> TableStructureCapabi
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) => {
@@ -322,7 +326,15 @@ fn build_table_comment_sql(options: &TableStructureSqlOptions, warnings: &mut Ve
StructureDialect::ClickHouse => {
vec![format!("ALTER TABLE {table} MODIFY COMMENT {quoted};")]
}
- StructureDialect::SqlServer | StructureDialect::Sqlite | StructureDialect::DuckDb | _ => {
+ StructureDialect::SqlServer => {
+ build_sqlserver_table_comment_sql(
+ &table,
+ options.schema.as_deref(),
+ &options.table_name,
+ new_comment,
+ )
+ }
+ StructureDialect::Sqlite | StructureDialect::DuckDb | _ => {
if !clean(new_comment).is_empty() {
warnings
.push(format!("Table comments are not supported for {} from this editor.", dialect_label(dialect)));
@@ -387,6 +399,13 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu
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,
+ ));
}
}
}
@@ -415,6 +434,19 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu
}
}
}
+ 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 {
@@ -424,7 +456,14 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu
));
continue;
}
- statements.extend(build_create_index_statements(dialect, &table, index, &mut warnings));
+ statements.extend(build_create_index_statements(
+ dialect,
+ &table,
+ index,
+ &mut warnings,
+ options.schema.as_deref(),
+ &options.table_name,
+ ));
}
TableStructureSqlResult { statements, warnings }
@@ -472,7 +511,14 @@ fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mut Vec {
+ 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.")),
}
@@ -620,7 +674,14 @@ fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut Vec,
+ table_name: &str,
) -> Vec {
let add_keyword = if dialect == StructureDialect::SqlServer { "ADD" } else { "ADD COLUMN" };
let definition = column_definition(dialect, column);
@@ -661,6 +731,15 @@ fn build_add_column_sql(
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
}
@@ -775,6 +854,85 @@ fn build_oracle_like_existing_column_sql(
statements
}
+fn build_sqlserver_existing_column_sql(
+ table: &str,
+ column: &EditableStructureColumn,
+ schema: Option<&str>,
+ table_name: &str,
+) -> Vec {
+ 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('.').last().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 {default_value} FOR {};",
+ 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
+}
+
fn build_h2_existing_column_sql(table: &str, column: &EditableStructureColumn) -> Vec {
let Some(original) = &column.original else {
return Vec::new();
@@ -930,6 +1088,8 @@ fn build_create_index_statements(
table: &str,
index: &EditableStructureIndex,
warnings: &mut Vec,
+ schema: Option<&str>,
+ table_name: &str,
) -> Vec {
let capabilities = capabilities_for(database_type_for_dialect(dialect));
let name = clean(&index.name);
@@ -992,6 +1152,14 @@ fn build_create_index_statements(
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)));
}
@@ -1318,6 +1486,92 @@ fn database_type_for_dialect(dialect: StructureDialect) -> Option
}
}
+fn sqlserver_schema_name(schema: Option<&str>) -> String {
+ schema.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()).unwrap_or_else(|| "dbo".to_string())
+}
+
+fn build_sqlserver_table_comment_sql(
+ qualified_table: &str,
+ schema: Option<&str>,
+ table_name: &str,
+ new_comment: &str,
+) -> Vec {
+ 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
+}
+
+fn build_sqlserver_index_comment_sql(
+ qualified_table: &str,
+ schema: Option<&str>,
+ table_name: &str,
+ index_name: &str,
+ new_comment: &str,
+) -> Vec {
+ 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
+}
+
+fn build_sqlserver_column_comment_sql(
+ qualified_table: &str,
+ schema: Option<&str>,
+ table_name: &str,
+ column_name: &str,
+ new_comment: &str,
+) -> Vec {
+ 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
+}
+
#[cfg(test)]
mod tests {
use super::*;