diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 55527e6ea..3baa67c67 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -831,7 +831,7 @@ pub async fn list_indexes(client: &mut SqlServerClient, schema: &str, table: &st } else { Some(inc_str.split(',').map(|s| s.to_string()).collect()) }, - comment: None, + comment: row.get::<&str, _>(7).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), } }) .collect()) @@ -853,8 +853,10 @@ fn sqlserver_indexes_sql(schema: &str, table: &str) -> String { WHERE ic3.object_id = i.object_id AND ic3.index_id = i.index_id AND ic3.is_included_column = 1 \ ORDER BY ic3.index_column_id \ FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, '') AS included_cols, \ - i.filter_definition \ + i.filter_definition, \ + ep.value AS index_comment \ FROM sys.indexes i \ + OUTER APPLY (SELECT CAST(ep.value AS NVARCHAR(MAX)) AS value FROM sys.extended_properties ep WHERE ep.major_id = i.object_id AND ep.minor_id = i.index_id AND ep.name = N'MS_Description' AND ep.class = 7) ep \ WHERE i.object_id = OBJECT_ID('{s}.{t}') AND i.name IS NOT NULL \ ORDER BY i.name", s = schema.replace('\'', "''"), @@ -893,6 +895,29 @@ pub async fn list_foreign_keys( .collect()) } +pub async fn get_table_comment( + client: &mut SqlServerClient, + schema: &str, + table: &str, +) -> Result, String> { + let sql = sqlserver_table_comment_sql(schema, table); + 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())?; + Ok(rows.first().and_then(|row| row.get::<&str, _>(0)).filter(|s| !s.is_empty()).map(|s| s.to_string())) +} + +fn sqlserver_table_comment_sql(schema: &str, table: &str) -> String { + let s = schema.replace('\'', "''"); + let t = table.replace('\'', "''"); + format!( + "SELECT CAST(ep.value AS NVARCHAR(MAX)) \ + FROM sys.extended_properties ep \ + WHERE ep.major_id = OBJECT_ID(QUOTENAME('{s}') + '.' + QUOTENAME('{t}')) \ + AND ep.minor_id = 0 \ + AND ep.name = N'MS_Description'" + ) +} + pub async fn list_triggers( client: &mut SqlServerClient, schema: &str, @@ -1084,7 +1109,7 @@ mod tests { use super::{ build_spatial_safe_sqlserver_query, is_sqlserver_spatial_column, requires_simple_query_batch, sqlserver_cell_to_json, sqlserver_columns_sql, sqlserver_indexes_sql, sqlserver_list_objects_sql, - SqlServerDescribedColumn, SqlServerResultSet, + sqlserver_table_comment_sql, SqlServerDescribedColumn, SqlServerResultSet, }; use chrono::NaiveDate; use std::time::Instant; @@ -1160,6 +1185,26 @@ mod tests { assert!(sql.contains("OBJECT_ID('dbo.DF_Rule')")); } + #[test] + fn sqlserver_indexes_sql_includes_index_comment_via_extended_properties() { + let sql = sqlserver_indexes_sql("dbo", "orders"); + + assert!(sql.contains("sys.extended_properties ep")); + assert!(sql.contains("ep.minor_id = i.index_id")); + assert!(sql.contains("MS_Description")); + } + + #[test] + fn sqlserver_table_comment_sql_queries_extended_properties() { + let sql = sqlserver_table_comment_sql("dbo", "users"); + + assert!(sql.contains("sys.extended_properties ep")); + assert!(sql.contains("ep.minor_id = 0")); + assert!(sql.contains("MS_Description")); + assert!(sql.contains("QUOTENAME('dbo')")); + assert!(sql.contains("QUOTENAME('users')")); + } + #[test] fn sqlserver_metadata_sql_escapes_literals() { let columns_sql = sqlserver_columns_sql("d'bo", "t'able"); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 35c6b0964..cc5f450af 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1452,6 +1452,67 @@ mod ddl_tests { "SELECT pg_get_tabledef('\"tenant''s schema\".\"active users\"')" ); } + + #[test] + fn sqlserver_comment_ddl_includes_table_comment() { + let ddl = render_sqlserver_comment_ddl("dbo", "users", Some("User accounts table"), &[], &[]); + + assert!(ddl.contains("EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'User accounts table'")); + assert!(ddl.contains("@level0type=N'SCHEMA', @level0name=N'dbo'")); + assert!(ddl.contains("@level1type=N'TABLE', @level1name=N'users'")); + } + + #[test] + fn sqlserver_comment_ddl_includes_column_comments() { + let mut email_col = column("email", "nvarchar(255)"); + email_col.comment = Some("User's email address".to_string()); + let mut name_col = column("name", "nvarchar(100)"); + name_col.comment = Some("User's display name".to_string()); + let columns = vec![email_col, name_col]; + + let ddl = render_sqlserver_comment_ddl("dbo", "users", None, &columns, &[]); + + assert!(ddl.contains("@level2type=N'COLUMN', @level2name=N'email'")); + assert!(ddl.contains("@value=N'User''s email address'")); + assert!(ddl.contains("@level2type=N'COLUMN', @level2name=N'name'")); + assert!(ddl.contains("@value=N'User''s display name'")); + } + + #[test] + fn sqlserver_comment_ddl_includes_index_comments() { + let idx = db::IndexInfo { + name: "IX_users_email".to_string(), + columns: vec!["email".to_string()], + is_unique: true, + is_primary: false, + filter: None, + index_type: Some("NONCLUSTERED".to_string()), + included_columns: None, + comment: Some("Unique email index".to_string()), + }; + let indexes = vec![idx]; + + let ddl = render_sqlserver_comment_ddl("dbo", "users", None, &[], &indexes); + + assert!(ddl.contains("@level2type=N'INDEX', @level2name=N'IX_users_email'")); + assert!(ddl.contains("@value=N'Unique email index'")); + } + + #[test] + fn sqlserver_comment_ddl_skips_empty_comments() { + let ddl = render_sqlserver_comment_ddl("dbo", "users", Some(""), &[], &[]); + assert!(ddl.is_empty()); + + let ddl = render_sqlserver_comment_ddl("dbo", "users", None, &[], &[]); + assert!(ddl.is_empty()); + } + + #[test] + fn sqlserver_comment_ddl_escapes_single_quotes() { + let ddl = render_sqlserver_comment_ddl("dbo", "users", Some("User's table for 'admin'"), &[], &[]); + + assert!(ddl.contains("@value=N'User''s table for ''admin'''")); + } } pub async fn mysql_ddl(pool: &db::mysql::MySqlPool, table: &str) -> Result { @@ -1592,6 +1653,7 @@ pub async fn build_sqlserver_ddl( let columns = db::sqlserver::get_columns(client, schema, table).await?; let indexes = db::sqlserver::list_indexes(client, schema, table).await?; let fkeys = db::sqlserver::list_foreign_keys(client, schema, table).await?; + let table_comment = db::sqlserver::get_table_comment(client, schema, table).await?; let mut ddl = format!("CREATE TABLE [{schema}].[{table}] (\n"); let col_lines: Vec = columns @@ -1643,5 +1705,61 @@ pub async fn build_sqlserver_ddl( idx.name )); } + + let comment_ddl = render_sqlserver_comment_ddl(schema, table, table_comment.as_deref(), &columns, &indexes); + if !comment_ddl.is_empty() { + ddl.push('\n'); + ddl.push_str(comment_ddl.trim_end()); + } + Ok(ddl) } + +fn render_sqlserver_comment_ddl( + schema: &str, + table: &str, + table_comment: Option<&str>, + columns: &[db::ColumnInfo], + indexes: &[db::IndexInfo], +) -> String { + let mut ddl = String::new(); + let escaped_schema = schema.replace('\'', "''"); + let escaped_table = table.replace('\'', "''"); + + if let Some(comment) = table_comment.filter(|s| !s.is_empty()) { + let escaped_comment = comment.replace('\'', "''"); + ddl.push_str(&format!( + "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', \ + @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', \ + @level1type=N'TABLE', @level1name=N'{escaped_table}';\n" + )); + } + + for col in columns { + if let Some(comment) = col.comment.as_deref().filter(|s| !s.is_empty()) { + let escaped_comment = comment.replace('\'', "''"); + let escaped_col = col.name.replace('\'', "''"); + ddl.push_str(&format!( + "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', \ + @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', \ + @level1type=N'TABLE', @level1name=N'{escaped_table}', \ + @level2type=N'COLUMN', @level2name=N'{escaped_col}';\n" + )); + } + } + + for idx in indexes { + if let Some(comment) = idx.comment.as_deref().filter(|s| !s.is_empty()) { + let escaped_comment = comment.replace('\'', "''"); + let escaped_idx = idx.name.replace('\'', "''"); + ddl.push_str(&format!( + "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', \ + @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', \ + @level1type=N'TABLE', @level1name=N'{escaped_table}', \ + @level2type=N'INDEX', @level2name=N'{escaped_idx}';\n" + )); + } + } + + ddl +}