fix(sqlserver): 表结构支持查看约束(主键/唯一/外键/检查/默认值) (#9581)

SQL Server 连接的「编辑表结构」面板里只有 DDL/字段/索引/外键/触发器,
没有「约束」页签;用户无法查看某张表的约束。

list_constraints 在 SQL Server 驱动中完全缺失:驱动侧没有对应实现,
dbx-core 的 list_constraints_core 没有 SQL Server 分支,直接落到默认的
空数组;前端 tableMetadataCapabilities 也没有声明 sqlserver 支持,
因此即使是空页签也不会渲染。

- dbx-drivers 新增 list_constraints,用一条 UNION ALL 查询汇总
  sys.key_constraints(PK/UNIQUE)、sys.foreign_keys(含引用库表列、
  ON UPDATE/DELETE 动作、is_not_trusted)、sys.check_constraints、
  sys.default_constraints;列名用 FOR XML PATH('') 聚合,兼容 SQL Server
  2008(无 STRING_AGG)
- list_constraints_core 增加 SQL Server 分支与链接服务器早退
- tableMetadataCapabilities 为 sqlserver 打开 constraints,前端显示「约束」页签
- 新增单测覆盖 SQL 形态与列名拆包,并补充前端能力断言

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipg
2026-09-19 13:31:45 +08:00
committed by GitHub
co-authored by zipg
parent ed1881e323
commit 74c275b2ce
6 changed files with 161 additions and 11 deletions
@@ -19,6 +19,7 @@ describe("tableMetadataCapabilities", () => {
expect(getTableMetadataCapabilities("kingbase").constraints).toBe(true);
expect(getTableMetadataCapabilities("vastbase").constraints).toBe(true);
expect(getTableMetadataCapabilities("opengauss").constraints).toBe(true);
expect(getTableMetadataCapabilities("sqlserver").constraints).toBe(true);
expect(getTableMetadataCapabilities("mysql").constraints).toBe(false);
expect(getTableMetadataCapabilities(undefined).constraints).toBe(false);
});
@@ -40,6 +40,11 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
postgres: {
constraints: true,
},
// SQL Server reports PK/UNIQUE/FOREIGN KEY/CHECK/DEFAULT constraints from the
// sys.* catalog views through list_constraints.
sqlserver: {
constraints: true,
},
mongodb: {
columns: false,
foreignKeys: false,
+6 -1
View File
@@ -7694,7 +7694,8 @@ pub async fn list_triggers_core(
/// Lists structured constraints for a relation. Exposed generically so the
/// agent protocol and native drivers share one route; the built-in drivers
/// that implement it today are PostgreSQL, OpenGauss, and the Xugu agent.
/// that implement it today are PostgreSQL, OpenGauss, SQL Server, and the
/// Xugu agent.
pub async fn list_constraints_core(
state: &AppState,
connection_id: &str,
@@ -7702,12 +7703,16 @@ pub async fn list_constraints_core(
schema: &str,
table: &str,
) -> Result<Vec<db::ConstraintInfo>, String> {
if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() {
return Ok(vec![]);
}
retry_metadata_connection(state, connection_id, Some(database), || async {
let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?;
let db_config = connection_config(state, connection_id).await;
{
let pool_handle = state.pool_handle(&pool_key).await;
try_sqlserver!(pool_handle, list_constraints, schema, table);
if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) {
let mut client = client.lock().await;
return client
+149 -10
View File
@@ -1,8 +1,8 @@
use crate::execution::MAX_ROWS;
use crate::sql::starts_with_executable_sql_keyword;
use crate::types::{
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, LinkedServerInfo, ObjectStatistics, QueryMessage, QueryResult,
SpatialColumnBuilder, TableInfo, TriggerInfo,
ColumnInfo, ConstraintInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, LinkedServerInfo, ObjectStatistics,
QueryMessage, QueryResult, SpatialColumnBuilder, TableInfo, TriggerInfo,
};
use futures::{FutureExt, TryStreamExt};
use sqlparser::ast::{Expr, Ident, ObjectNamePart, OrderByKind, SelectItem, SetExpr, Statement, TableFactor, Value};
@@ -3107,6 +3107,118 @@ fn sqlserver_triggers_sql(schema: &str, table: &str) -> String {
)
}
pub async fn list_constraints(
client: &mut SqlServerClient,
schema: &str,
table: &str,
) -> Result<Vec<ConstraintInfo>, String> {
let sql = sqlserver_constraints_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
.iter()
.map(|row| {
let columns = row.get::<&str, _>(3).unwrap_or("");
let ref_columns = row.get::<&str, _>(6).unwrap_or("");
ConstraintInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
constraint_type: row.get::<&str, _>(1).unwrap_or("").to_string(),
definition: row.get::<&str, _>(2).unwrap_or("").to_string(),
columns: sqlserver_split_name_list(columns),
ref_schema: row.get::<&str, _>(4).filter(|value| !value.is_empty()).map(str::to_string),
ref_table: row.get::<&str, _>(5).filter(|value| !value.is_empty()).map(str::to_string),
ref_columns: sqlserver_split_name_list(ref_columns),
match_type: None,
on_update: row.get::<&str, _>(7).filter(|value| !value.is_empty()).map(str::to_string),
on_delete: row.get::<&str, _>(8).filter(|value| !value.is_empty()).map(str::to_string),
deferrable: false,
initially_deferred: false,
enabled: row.get::<bool, _>(9).unwrap_or(true),
valid: row.get::<bool, _>(10).unwrap_or(true),
}
})
.collect())
}
fn sqlserver_split_name_list(value: &str) -> Vec<String> {
value.split(',').filter(|name| !name.is_empty()).map(|name| name.to_string()).collect()
}
/// One row per constraint (PK / UNIQUE / FOREIGN KEY / CHECK / DEFAULT) for a table.
///
/// `sys.key_constraints` and `sys.foreign_keys` are keyed by their backing index
/// and constraint object respectively, so the participating columns are
/// aggregated with the same `FOR XML PATH('')` trick the index listing uses.
fn sqlserver_constraints_sql(schema: &str, table: &str) -> String {
let object_id = sqlserver_object_id_expression(schema, table);
let key_columns = "STUFF((SELECT ',' + c2.name \
FROM sys.index_columns ic2 \
JOIN sys.columns c2 ON c2.object_id = ic2.object_id AND c2.column_id = ic2.column_id \
WHERE ic2.object_id = kc.parent_object_id AND ic2.index_id = kc.unique_index_id AND ic2.is_included_column = 0 \
ORDER BY ic2.key_ordinal \
FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, '')";
let foreign_key_columns = "STUFF((SELECT ',' + pc2.name \
FROM sys.foreign_key_columns fkc2 \
JOIN sys.columns pc2 ON pc2.object_id = fkc2.parent_object_id AND pc2.column_id = fkc2.parent_column_id \
WHERE fkc2.constraint_object_id = fk.object_id \
ORDER BY fkc2.constraint_column_id \
FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, '')";
let foreign_key_ref_columns = "STUFF((SELECT ',' + rc2.name \
FROM sys.foreign_key_columns fkc2 \
JOIN sys.columns rc2 ON rc2.object_id = fkc2.referenced_object_id AND rc2.column_id = fkc2.referenced_column_id \
WHERE fkc2.constraint_object_id = fk.object_id \
ORDER BY fkc2.constraint_column_id \
FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, '')";
format!(
"SELECT c.constraint_name, c.constraint_type, c.definition, c.constraint_columns, \
c.ref_schema, c.ref_table, c.ref_columns, c.on_update, c.on_delete, c.is_enabled, c.is_valid \
FROM ( \
SELECT kc.name AS constraint_name, \
CASE WHEN kc.type = 'PK' THEN N'PRIMARY KEY' ELSE N'UNIQUE' END AS constraint_type, \
CAST(N'' AS NVARCHAR(MAX)) AS definition, \
{key_columns} AS constraint_columns, \
CAST(NULL AS NVARCHAR(128)) AS ref_schema, \
CAST(NULL AS NVARCHAR(128)) AS ref_table, \
CAST(N'' AS NVARCHAR(MAX)) AS ref_columns, \
CAST(NULL AS NVARCHAR(60)) AS on_update, \
CAST(NULL AS NVARCHAR(60)) AS on_delete, \
CAST(CASE WHEN i.is_disabled = 1 THEN 0 ELSE 1 END AS bit) AS is_enabled, \
CAST(1 AS bit) AS is_valid \
FROM sys.key_constraints kc \
JOIN sys.indexes i ON i.object_id = kc.parent_object_id AND i.index_id = kc.unique_index_id \
WHERE kc.parent_object_id = {object_id} \
UNION ALL \
SELECT fk.name, N'FOREIGN KEY', CAST(N'' AS NVARCHAR(MAX)), {foreign_key_columns}, \
SCHEMA_NAME(rt.schema_id), rt.name, {foreign_key_ref_columns}, \
CASE fk.update_referential_action WHEN 1 THEN N'CASCADE' WHEN 2 THEN N'SET NULL' WHEN 3 THEN N'SET DEFAULT' ELSE N'NO ACTION' END, \
CASE fk.delete_referential_action WHEN 1 THEN N'CASCADE' WHEN 2 THEN N'SET NULL' WHEN 3 THEN N'SET DEFAULT' ELSE N'NO ACTION' END, \
CAST(CASE WHEN fk.is_disabled = 1 THEN 0 ELSE 1 END AS bit), \
CAST(CASE WHEN fk.is_not_trusted = 1 THEN 0 ELSE 1 END AS bit) \
FROM sys.foreign_keys fk \
JOIN sys.tables rt ON rt.object_id = fk.referenced_object_id \
WHERE fk.parent_object_id = {object_id} \
UNION ALL \
SELECT cc.name, N'CHECK', cc.definition, ISNULL(c.name, N''), \
CAST(NULL AS NVARCHAR(128)), CAST(NULL AS NVARCHAR(128)), CAST(N'' AS NVARCHAR(MAX)), \
CAST(NULL AS NVARCHAR(60)), CAST(NULL AS NVARCHAR(60)), \
CAST(CASE WHEN cc.is_disabled = 1 THEN 0 ELSE 1 END AS bit), \
CAST(CASE WHEN cc.is_not_trusted = 1 THEN 0 ELSE 1 END AS bit) \
FROM sys.check_constraints cc \
LEFT JOIN sys.columns c ON c.object_id = cc.parent_object_id AND c.column_id = cc.parent_column_id \
WHERE cc.parent_object_id = {object_id} \
UNION ALL \
SELECT dc.name, N'DEFAULT', dc.definition, ISNULL(c.name, N''), \
CAST(NULL AS NVARCHAR(128)), CAST(NULL AS NVARCHAR(128)), CAST(N'' AS NVARCHAR(MAX)), \
CAST(NULL AS NVARCHAR(60)), CAST(NULL AS NVARCHAR(60)), \
CAST(1 AS bit), CAST(1 AS bit) \
FROM sys.default_constraints dc \
LEFT JOIN sys.columns c ON c.object_id = dc.parent_object_id AND c.column_id = dc.parent_column_id \
WHERE dc.parent_object_id = {object_id} \
) c \
ORDER BY c.constraint_name",
)
}
pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result<QueryResult, String> {
execute_query_with_max_rows(client, sql, None).await
}
@@ -3674,17 +3786,18 @@ mod tests {
requires_simple_query_batch, restore_sqlserver_blank_column_names, restore_sqlserver_legacy_probe_output_names,
restore_sqlserver_spatial_column_types, restore_sqlserver_unsafe_column_types, server_messages_query_result,
sqlserver_batch_can_use_execute, sqlserver_bulk_token_row, sqlserver_cell_to_json, sqlserver_columns_sql,
sqlserver_completion_assistant_sql, sqlserver_dml_output_returns_rows, sqlserver_done_trace_event,
sqlserver_filter_definition_error, sqlserver_hidden_schema_names, sqlserver_indexes_sql,
sqlserver_legacy_indexes_sql, sqlserver_legacy_probe, sqlserver_legacy_probe_with_nonce,
sqlserver_completion_assistant_sql, sqlserver_constraints_sql, sqlserver_dml_output_returns_rows,
sqlserver_done_trace_event, sqlserver_filter_definition_error, sqlserver_hidden_schema_names,
sqlserver_indexes_sql, sqlserver_legacy_indexes_sql, sqlserver_legacy_probe, sqlserver_legacy_probe_with_nonce,
sqlserver_legacy_wildcard_metadata_query, sqlserver_list_objects_sql, sqlserver_list_schemas_sql,
sqlserver_list_tables_sql, sqlserver_probe_explicit_alias, sqlserver_query_messages,
sqlserver_query_transport_for_engine_edition, sqlserver_query_transport_for_request,
sqlserver_schema_name_predicate, sqlserver_spatial_marker, sqlserver_supports_session_database_switch,
sqlserver_table_comment_sql, sqlserver_table_objects_sql, sqlserver_triggers_sql,
sqlserver_visible_object_predicate, strip_dbx_sqlserver_row_number_column, SqlServerDescribedColumn,
SqlServerProbeOutputNameOverride, SqlServerQueryTransport, SqlServerRestoredColumn, SqlServerResultSet,
SqlServerSpatialColumn, SqlServerTdsEvent, SQLSERVER_COMPLETION_CONTEXT_SQL, SQLSERVER_RESULT_TYPE_PROBE_SQL,
sqlserver_schema_name_predicate, sqlserver_spatial_marker, sqlserver_split_name_list,
sqlserver_supports_session_database_switch, sqlserver_table_comment_sql, sqlserver_table_objects_sql,
sqlserver_triggers_sql, sqlserver_visible_object_predicate, strip_dbx_sqlserver_row_number_column,
SqlServerDescribedColumn, SqlServerProbeOutputNameOverride, SqlServerQueryTransport, SqlServerRestoredColumn,
SqlServerResultSet, SqlServerSpatialColumn, SqlServerTdsEvent, SQLSERVER_COMPLETION_CONTEXT_SQL,
SQLSERVER_RESULT_TYPE_PROBE_SQL,
};
use crate::types::{
CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, QueryResult,
@@ -4523,6 +4636,32 @@ mod tests {
assert!(!sql.contains("STRING_AGG"));
}
#[test]
fn sqlserver_constraints_sql_unions_every_constraint_catalog() {
let sql = sqlserver_constraints_sql("d'bo", "t'able");
assert!(sql.contains("sys.key_constraints"));
assert!(sql.contains("sys.foreign_keys"));
assert!(sql.contains("sys.check_constraints"));
assert!(sql.contains("sys.default_constraints"));
assert!(sql.contains("UNION ALL"));
// Participating columns are aggregated with the same FOR XML trick the
// index listing uses; SQL Server has no string_agg on 2008-era servers.
assert!(sql.contains("FOR XML PATH(''), TYPE"));
assert!(!sql.contains("STRING_AGG"));
// The constraint order is stable so the tab does not reshuffle per refresh.
assert!(sql.contains("ORDER BY c.constraint_name"));
// Literals stay escaped through the shared object-id helper.
assert!(sql.contains("OBJECT_ID(QUOTENAME(N'd''bo') + N'.' + QUOTENAME(N't''able'))"));
}
#[test]
fn sqlserver_split_name_list_drops_trailing_empty_entries() {
assert_eq!(sqlserver_split_name_list(""), Vec::<String>::new());
assert_eq!(sqlserver_split_name_list("id"), vec!["id".to_string()]);
assert_eq!(sqlserver_split_name_list("id,code"), vec!["id".to_string(), "code".to_string()]);
}
#[test]
fn sqlserver_metadata_sql_escapes_literals() {
let columns_sql = sqlserver_columns_sql("d'bo", "t'able");
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB