From 9fecf71252d54f7978649cd59669919e134cf7ff Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 16 Sep 2026 08:08:19 +0000 Subject: [PATCH] feat: expose SQL Server metadata comments --- src/driver/introspection.rs | 32 ++++++++++-- src/driver/introspection/tests.rs | 86 ++++++++++++++++++++++++++++++- src/models.rs | 4 ++ tests/conformance.rs | 22 +++++++- 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/src/driver/introspection.rs b/src/driver/introspection.rs index d0b3149..a9c94fc 100644 --- a/src/driver/introspection.rs +++ b/src/driver/introspection.rs @@ -18,9 +18,16 @@ use std::collections::HashMap; // --- SQL query constants -------------------------------------------------- pub const Q_GET_TABLES: &str = "\ -SELECT t.name \ +SELECT \ + t.name, \ + TRY_CONVERT(nvarchar(max), ep.value) AS comment \ FROM sys.tables t \ JOIN sys.schemas s ON t.schema_id = s.schema_id \ +LEFT JOIN sys.extended_properties ep \ + ON ep.class = 1 \ + AND ep.major_id = t.object_id \ + AND ep.minor_id = 0 \ + AND ep.name = N'MS_Description' \ WHERE s.name = @P1 \ ORDER BY t.name"; @@ -49,12 +56,18 @@ SELECT \ AND ic.column_id = c.column_id \ AND i.is_primary_key = 1 \ ), 0) AS BIT) AS is_pk, \ - dc.definition AS default_value \ + dc.definition AS default_value, \ + TRY_CONVERT(nvarchar(max), ep.value) AS comment \ FROM sys.columns c \ JOIN sys.types ty ON c.user_type_id = ty.user_type_id \ LEFT JOIN sys.default_constraints dc \ ON dc.parent_object_id = c.object_id \ AND dc.parent_column_id = c.column_id \ +LEFT JOIN sys.extended_properties ep \ + ON ep.class = 1 \ + AND ep.major_id = c.object_id \ + AND ep.minor_id = c.column_id \ + AND ep.name = N'MS_Description' \ WHERE c.object_id = OBJECT_ID(@P1) \ ORDER BY c.column_id"; @@ -204,7 +217,8 @@ SELECT \ AND ic.column_id = c.column_id \ AND i.is_primary_key = 1 \ ), 0) AS BIT) AS is_pk, \ - dc.definition AS default_value \ + dc.definition AS default_value, \ + TRY_CONVERT(nvarchar(max), ep.value) AS comment \ FROM sys.columns c \ JOIN sys.tables t ON c.object_id = t.object_id \ JOIN sys.schemas s ON t.schema_id = s.schema_id \ @@ -212,6 +226,11 @@ JOIN sys.types ty ON c.user_type_id = ty.user_type_id \ LEFT JOIN sys.default_constraints dc \ ON dc.parent_object_id = c.object_id \ AND dc.parent_column_id = c.column_id \ +LEFT JOIN sys.extended_properties ep \ + ON ep.class = 1 \ + AND ep.major_id = c.object_id \ + AND ep.minor_id = c.column_id \ + AND ep.name = N'MS_Description' \ WHERE s.name = @P1 \ ORDER BY t.name, c.column_id"; @@ -369,6 +388,7 @@ pub fn build_table_column( max_length_bytes: i32, is_pk: bool, default_value: Option, + comment: Option, ) -> TableColumn { let character_maximum_length = if is_string_type(&data_type) { character_length_from_sys_columns(&data_type, max_length_bytes) @@ -384,6 +404,7 @@ pub fn build_table_column( is_generated, default_value, character_maximum_length, + comment, } } @@ -474,8 +495,9 @@ pub async fn get_tables( Ok(rows .into_iter() .filter_map(|r| { - r.get::<&str, _>(0).map(|n| TableInfo { + r.get::<&str, _>("name").map(|n| TableInfo { name: n.to_string(), + comment: row_str_opt(&r, "comment"), }) }) .collect()) @@ -505,6 +527,7 @@ pub async fn get_columns( row_i32(&r, "max_length"), row_bool(&r, "is_pk"), row_str_opt(&r, "default_value"), + row_str_opt(&r, "comment"), ) }) .collect()) @@ -597,6 +620,7 @@ pub async fn get_all_columns_batch( row_i32(&r, "max_length"), row_bool(&r, "is_pk"), row_str_opt(&r, "default_value"), + row_str_opt(&r, "comment"), ); out.entry(table_name).or_default().push(col); } diff --git a/src/driver/introspection/tests.rs b/src/driver/introspection/tests.rs index 66baa63..b68330e 100644 --- a/src/driver/introspection/tests.rs +++ b/src/driver/introspection/tests.rs @@ -3,9 +3,15 @@ use super::*; // --- Query shape assertions (no live server needed) ------------------- #[test] -fn q_get_tables_queries_sys_tables_and_schemas() { +fn q_get_tables_queries_descriptions_with_object_scope() { assert!(Q_GET_TABLES.contains("sys.tables")); assert!(Q_GET_TABLES.contains("sys.schemas")); + assert!(Q_GET_TABLES.contains("sys.extended_properties ep")); + assert!(Q_GET_TABLES.contains("ep.class = 1")); + assert!(Q_GET_TABLES.contains("ep.major_id = t.object_id")); + assert!(Q_GET_TABLES.contains("ep.minor_id = 0")); + assert!(Q_GET_TABLES.contains("ep.name = N'MS_Description'")); + assert!(Q_GET_TABLES.contains("TRY_CONVERT(nvarchar(max), ep.value) AS comment")); assert!(Q_GET_TABLES.contains("@P1")); assert!(Q_GET_TABLES.contains("ORDER BY t.name")); } @@ -18,6 +24,12 @@ fn q_get_columns_joins_sys_types_and_reports_pk() { assert!(Q_GET_COLUMNS.contains("sys.indexes")); assert!(Q_GET_COLUMNS.contains("is_primary_key")); assert!(Q_GET_COLUMNS.contains("sys.default_constraints")); + assert!(Q_GET_COLUMNS.contains("sys.extended_properties ep")); + assert!(Q_GET_COLUMNS.contains("ep.class = 1")); + assert!(Q_GET_COLUMNS.contains("ep.major_id = c.object_id")); + assert!(Q_GET_COLUMNS.contains("ep.minor_id = c.column_id")); + assert!(Q_GET_COLUMNS.contains("ep.name = N'MS_Description'")); + assert!(Q_GET_COLUMNS.contains("TRY_CONVERT(nvarchar(max), ep.value) AS comment")); assert!(Q_GET_COLUMNS.contains("c.is_computed AS is_generated")); assert!(Q_GET_COLUMNS.contains("OBJECT_ID(@P1)")); assert!(Q_GET_COLUMNS.contains("ORDER BY c.column_id")); @@ -150,6 +162,12 @@ fn q_get_all_columns_batch_groups_by_table() { assert!(Q_GET_ALL_COLUMNS_BATCH.contains("sys.tables")); assert!(Q_GET_ALL_COLUMNS_BATCH.contains("sys.schemas")); assert!(Q_GET_ALL_COLUMNS_BATCH.contains("sys.types")); + assert!(Q_GET_ALL_COLUMNS_BATCH.contains("sys.extended_properties ep")); + assert!(Q_GET_ALL_COLUMNS_BATCH.contains("ep.class = 1")); + assert!(Q_GET_ALL_COLUMNS_BATCH.contains("ep.major_id = c.object_id")); + assert!(Q_GET_ALL_COLUMNS_BATCH.contains("ep.minor_id = c.column_id")); + assert!(Q_GET_ALL_COLUMNS_BATCH.contains("ep.name = N'MS_Description'")); + assert!(Q_GET_ALL_COLUMNS_BATCH.contains("TRY_CONVERT(nvarchar(max), ep.value) AS comment")); assert!(Q_GET_ALL_COLUMNS_BATCH.contains("@P1")); assert!(Q_GET_ALL_COLUMNS_BATCH.contains("ORDER BY t.name, c.column_id")); // Must emit the table name so the caller can group rows. @@ -194,6 +212,7 @@ fn build_table_column_populates_string_length() { 40, false, None, + None, ); assert_eq!(col.name, "note"); assert_eq!(col.data_type, "nvarchar"); @@ -206,7 +225,17 @@ fn build_table_column_populates_string_length() { #[test] fn build_table_column_leaves_length_none_for_numeric() { - let col = build_table_column("id".into(), "int".into(), false, true, false, 4, true, None); + let col = build_table_column( + "id".into(), + "int".into(), + false, + true, + false, + 4, + true, + None, + None, + ); assert_eq!(col.character_maximum_length, None); assert!(col.is_pk); assert!(col.is_auto_increment); @@ -224,6 +253,7 @@ fn build_table_column_honours_max_as_none() { -1, false, None, + None, ); assert_eq!(col.character_maximum_length, None); } @@ -239,6 +269,7 @@ fn build_table_column_carries_default_value() { 8, false, Some("(getdate())".into()), + None, ); assert_eq!(col.default_value, Some("(getdate())".into())); assert_eq!(col.character_maximum_length, None); @@ -255,11 +286,62 @@ fn build_table_column_reports_generated_and_parameterized_lengths() { 84, false, None, + None, ); assert!(col.is_generated); assert_eq!(col.character_maximum_length, Some(42)); } +#[test] +fn metadata_descriptions_serialize_verbatim_and_omit_absent_values() { + let comment = "Owner's résumé\n次の行"; + let table = TableInfo { + name: "notes".into(), + comment: Some(comment.into()), + }; + let table_json = serde_json::to_value(&table).expect("serialize table metadata"); + assert_eq!(table_json["comment"], comment); + + let plain_table = TableInfo { + name: "plain".into(), + comment: None, + }; + let plain_table_json = + serde_json::to_value(&plain_table).expect("serialize table metadata without comment"); + assert!(plain_table_json.get("comment").is_none()); + + let col = build_table_column( + "note".into(), + "nvarchar(100)".into(), + true, + false, + false, + 200, + false, + None, + Some(comment.into()), + ); + assert_eq!(col.comment.as_deref(), Some(comment)); + + let json = serde_json::to_value(&col).expect("serialize column metadata"); + assert_eq!(json["comment"], comment); + + let plain_col = build_table_column( + "plain".into(), + "int".into(), + true, + false, + false, + 4, + false, + None, + None, + ); + let plain_col_json = + serde_json::to_value(&plain_col).expect("serialize column metadata without comment"); + assert!(plain_col_json.get("comment").is_none()); +} + // --- build_foreign_keys ---------------------------------------------- #[test] diff --git a/src/models.rs b/src/models.rs index a2c6b30..f8b4374 100644 --- a/src/models.rs +++ b/src/models.rs @@ -67,6 +67,8 @@ pub struct ConnectionParams { #[derive(Debug, Serialize, Deserialize)] pub struct TableInfo { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -82,6 +84,8 @@ pub struct TableColumn { pub default_value: Option, #[serde(skip_serializing_if = "Option::is_none")] pub character_maximum_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/tests/conformance.rs b/tests/conformance.rs index d0db5b4..011ad6f 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -2,7 +2,7 @@ //! //! The model definitions below are copied verbatim from //! `tabularis/src-tauri/src/models.rs` at host commit -//! `ba0463d3b861ec8fad110126c67e3fc12bac9839`. Re-sync them and regenerate +//! `3f0780e19191b3d6721ba7e5d8c1224fb8f4e8fe`. Re-sync them and regenerate //! `tests/fixtures/conformance/` with `python3 tests/capture_conformance.py` //! whenever the host models or plugin RPC surface changes. @@ -20,6 +20,8 @@ use serde_json::Value; #[derive(Debug, Serialize, Deserialize)] pub struct TableInfo { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -35,6 +37,8 @@ pub struct TableColumn { pub default_value: Option, #[serde(skip_serializing_if = "Option::is_none")] pub character_maximum_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -378,7 +382,23 @@ fn drift_prone_wire_fields_are_exercised() { .is_some()); assert!(batch[1].error.is_some()); + let tables: Vec = serde_json::from_value(fixture_result("get_tables")).unwrap(); + assert!(tables.iter().all(|table| table.comment.is_none())); + let mut commented_table = fixture_result("get_tables")[0].clone(); + commented_table["comment"] = Value::String("Owner's résumé\n次の行".into()); + let commented_table: TableInfo = serde_json::from_value(commented_table).unwrap(); + assert_eq!( + commented_table.comment.as_deref(), + Some("Owner's résumé\n次の行") + ); + let columns: Vec = serde_json::from_value(fixture_result("get_columns")).unwrap(); + assert!(columns.iter().all(|column| column.comment.is_none())); + let mut commented_column = fixture_result("get_columns")[0].clone(); + commented_column["comment"] = Value::String("L'état naïve\nΔ".into()); + let commented_column: TableColumn = serde_json::from_value(commented_column).unwrap(); + assert_eq!(commented_column.comment.as_deref(), Some("L'état naïve\nΔ")); + let label = columns .iter() .find(|column| column.name == "label")