From 634e8bf5acdfe7ade93cb36873cc6cc60c1f5a53 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 27 May 2026 22:28:31 +0800 Subject: [PATCH] feat(sqlserver): enable table structure editing for existing columns and comments - Enable renameColumn, alterExistingColumn, alterType, alterNullability, alterDefault, comment, and indexComment capabilities for SQL Server - Implement build_sqlserver_existing_column_sql() with sp_rename for column rename, ALTER TABLE ALTER COLUMN for type/nullability changes, and dynamic SQL for default constraint management - Add sp_addextendedproperty/sp_dropextendedproperty DDL generation for table, column, and index comments - Fetch table and column comments from sys.extended_properties via OUTER APPLY in list_tables, list_objects, and get_columns SQL queries - Fix table comment placeholder text (was showing column comment placeholder) --- .../structure/TableStructureEditor.vue | 2 +- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + .../src/lib/tableStructureCapabilities.ts | 7 + crates/dbx-core/src/db/sqlserver.rs | 35 ++- crates/dbx-core/src/table_structure_sql.rs | 264 +++++++++++++++++- 7 files changed, 291 insertions(+), 20 deletions(-) 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::*;