fix(sqlserver): tolerate tinyint foreign key referential actions

This commit is contained in:
zipg authored and GitHub committed 2026-09-21 16:38:47 +08:00
1 parent d0bce25b4b
commit 32389f7dc2
3 files changed
+304 -27

No files matched your search

@@ -0,0 +1,87 @@
//! Live regression coverage for the DDL path of a SQL Server table that owns a
//! foreign key: `sys.foreign_keys.delete_referential_action` /
//! `update_referential_action` are `tinyint` and used to be read with
//! `row.get::<i32>()`, which panicked inside tiberius and aborted the process
//! (the desktop app crashed on "查看 DDL" and on the hover DDL preview).
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::query::execute_sql_statement;
use dbx_core::storage::Storage;
use std::sync::Arc;
fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig {
let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").expect("DBX_LIVE_SQLSERVER_HOST");
let port: u16 = std::env::var("DBX_LIVE_SQLSERVER_PORT")
.expect("DBX_LIVE_SQLSERVER_PORT")
.parse()
.expect("valid DBX_LIVE_SQLSERVER_PORT");
let username = std::env::var("DBX_LIVE_SQLSERVER_USER").expect("DBX_LIVE_SQLSERVER_USER");
let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD");
serde_json::from_value::<ConnectionConfig>(serde_json::json!({
"id": id,
"name": id,
"db_type": DatabaseType::SqlServer,
"host": host,
"port": port,
"username": username,
"password": password,
"database": database,
"connect_timeout_secs": 15,
"query_timeout_secs": 30,
"idle_timeout_secs": 60,
"keepalive_interval_secs": 0
}))
.expect("live SQL Server config should deserialize")
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server"]
async fn live_sqlserver_display_ddl_reads_foreign_key_actions() {
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("live-sqlserver-ddl-fk-{suffix}");
let database = format!("dbx_ddl_fk_{suffix}");
let dir = std::env::temp_dir().join(format!("dbx-live-sqlserver-ddl-fk-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = Arc::new(AppState::new(storage));
state.configs.write().await.insert(connection_id.clone(), live_sqlserver_config(&connection_id, "master"));
let parent = format!("dbx_ddl_parent_{suffix}");
let child = format!("dbx_ddl_child_{suffix}");
for sql in [
format!("CREATE DATABASE [{database}]"),
format!("CREATE TABLE [dbo].[{parent}] ([id] INT NOT NULL CONSTRAINT [PK_{parent}] PRIMARY KEY CLUSTERED ([id] ASC))"),
format!(
"CREATE TABLE [dbo].[{child}] ([id] INT NOT NULL CONSTRAINT [PK_{child}] PRIMARY KEY CLUSTERED ([id] ASC), \
[parent_id] INT NULL, CONSTRAINT [FK_{child}_parent] FOREIGN KEY ([parent_id]) \
REFERENCES [dbo].[{parent}] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION)"
),
] {
let sql = if sql.starts_with("CREATE TABLE") { format!("USE [{database}]; {sql}") } else { sql };
execute_sql_statement(&state, &connection_id, "", &sql, None, None)
.await
.unwrap_or_else(|error| panic!("fixture statement failed: {error}"));
}
// The desktop app calls exactly this path for the DDL tab and the hover preview.
let ddl = dbx_core::schema::get_table_display_ddl_core(&state, &connection_id, &database, "dbo", &child, None)
.await
.expect("display DDL for a table owning a foreign key");
let cleanup = execute_sql_statement(
&state,
&connection_id,
"",
&format!("USE [master]; ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}]"),
None,
None,
)
.await;
assert!(ddl.contains("FOREIGN KEY ([parent_id])"), "DDL keeps the foreign key: {ddl}");
assert!(ddl.contains("ON DELETE CASCADE"), "DDL keeps the delete action: {ddl}");
assert!(!ddl.contains("ON UPDATE"), "NO ACTION stays implicit: {ddl}");
cleanup.expect("drop the live SQL Server DDL fixture database");
}
@@ -0,0 +1,92 @@
use std::time::Duration;
/// `sys.foreign_keys.delete_referential_action` and `update_referential_action` are
/// `tinyint`, which tiberius decodes as `ColumnData::U8`. Reading them with
/// `row.get::<i32>()` panicked and aborted the process, so schema comparison crashed
/// on the first table that owned a foreign key. This covers the live read path.
#[tokio::test]
#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"]
async fn live_sqlserver_foreign_key_referential_actions_are_read_from_tinyint_columns() {
let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").expect("DBX_LIVE_SQLSERVER_HOST");
let port = std::env::var("DBX_LIVE_SQLSERVER_PORT")
.expect("DBX_LIVE_SQLSERVER_PORT")
.parse()
.expect("valid DBX_LIVE_SQLSERVER_PORT");
let user = std::env::var("DBX_LIVE_SQLSERVER_USER").expect("DBX_LIVE_SQLSERVER_USER");
let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD");
let database = std::env::var("DBX_LIVE_SQLSERVER_DATABASE").unwrap_or_else(|_| "tempdb".to_string());
let mut client =
dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(15))
.await
.expect("connect SQL Server");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let parent_table = format!("dbx_fk_parent_{suffix}");
let cascade_child_table = format!("dbx_fk_cascade_child_{suffix}");
let set_null_child_table = format!("dbx_fk_setnull_child_{suffix}");
let cleanup = format!(
"DROP TABLE IF EXISTS [dbo].[{cascade_child_table}];\
DROP TABLE IF EXISTS [dbo].[{set_null_child_table}];\
DROP TABLE IF EXISTS [dbo].[{parent_table}];"
);
let _ = dbx_core::db::sqlserver::execute_batch(&mut client, &cleanup).await;
let setup = format!(
"CREATE TABLE [dbo].[{parent_table}] (\
[id] INT NOT NULL CONSTRAINT [PK_{parent_table}] PRIMARY KEY CLUSTERED ([id] ASC)\
);\
CREATE TABLE [dbo].[{cascade_child_table}] (\
[id] INT NOT NULL CONSTRAINT [PK_{cascade_child_table}] PRIMARY KEY CLUSTERED ([id] ASC),\
[parent_id] INT NULL,\
CONSTRAINT [FK_{cascade_child_table}_parent] FOREIGN KEY ([parent_id])\
REFERENCES [dbo].[{parent_table}] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION\
);\
CREATE TABLE [dbo].[{set_null_child_table}] (\
[id] INT NOT NULL CONSTRAINT [PK_{set_null_child_table}] PRIMARY KEY CLUSTERED ([id] ASC),\
[parent_id] INT NULL,\
CONSTRAINT [FK_{set_null_child_table}_parent] FOREIGN KEY ([parent_id])\
REFERENCES [dbo].[{parent_table}] ([id]) ON DELETE SET NULL ON UPDATE SET DEFAULT\
);"
);
dbx_core::db::sqlserver::execute_batch(&mut client, &setup).await.expect("create foreign key fixtures");
let exercise = async {
let cascade = dbx_core::db::sqlserver::list_foreign_keys(&mut client, "dbo", &cascade_child_table).await?;
assert_eq!(cascade.len(), 1, "expected one foreign key on the cascade child table");
assert_eq!(cascade[0].ref_table, parent_table);
assert_eq!(cascade[0].ref_column.as_str(), "id");
assert_eq!(cascade[0].column.as_str(), "parent_id");
assert_eq!(cascade[0].on_delete.as_deref(), Some("CASCADE"));
assert_eq!(cascade[0].on_update, None, "ON UPDATE NO ACTION is the default and stays unset");
let set_null = dbx_core::db::sqlserver::list_foreign_keys(&mut client, "dbo", &set_null_child_table).await?;
assert_eq!(set_null.len(), 1, "expected one foreign key on the set-null child table");
assert_eq!(set_null[0].on_delete.as_deref(), Some("SET NULL"));
assert_eq!(set_null[0].on_update.as_deref(), Some("SET DEFAULT"));
let parent = dbx_core::db::sqlserver::list_foreign_keys(&mut client, "dbo", &parent_table).await?;
assert!(parent.is_empty(), "parent table owns no outbound foreign keys");
// Mirror the schema comparison loop, which loads every table's metadata and
// DDL one table at a time and used to abort on the first table with a
// foreign key.
let tables = dbx_core::db::sqlserver::list_tables(&mut client, "dbo", None, None, None).await?;
for table in tables.iter().filter(|table| table.table_type.eq_ignore_ascii_case("BASE TABLE")) {
dbx_core::db::sqlserver::list_foreign_keys(&mut client, "dbo", &table.name)
.await
.unwrap_or_else(|error| panic!("list_foreign_keys failed for {}: {error}", table.name));
dbx_core::schema::build_sqlserver_ddl(&mut client, "dbo", &table.name)
.await
.unwrap_or_else(|error| panic!("build_sqlserver_ddl failed for {}: {error}", table.name));
}
Ok::<(), String>(())
}
.await;
let cleanup_result = dbx_core::db::sqlserver::execute_batch(&mut client, &cleanup).await;
exercise.expect("read referential actions from tinyint catalog columns");
cleanup_result.expect("drop foreign key fixtures");
}
+125 -27
View File
@@ -3015,7 +3015,25 @@ pub async fn list_foreign_keys(
schema: &str,
table: &str,
) -> Result<Vec<ForeignKeyInfo>, String> {
let sql = format!(
let sql = sqlserver_foreign_keys_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| ForeignKeyInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
column: row.get::<&str, _>(1).unwrap_or("").to_string(),
ref_schema: Some(row.get::<&str, _>(2).unwrap_or("").to_string()),
ref_table: row.get::<&str, _>(3).unwrap_or("").to_string(),
ref_column: row.get::<&str, _>(4).unwrap_or("").to_string(),
on_update: sqlserver_referential_action_from_row(row, 6),
on_delete: sqlserver_referential_action_from_row(row, 5),
})
.collect())
}
fn sqlserver_foreign_keys_sql(schema: &str, table: &str) -> String {
format!(
"SELECT fk.name, c.name, SCHEMA_NAME(rt.schema_id), rt.name, rc.name, \
fk.delete_referential_action, fk.update_referential_action \
FROM sys.foreign_keys fk \
@@ -3027,21 +3045,36 @@ pub async fn list_foreign_keys(
ORDER BY fk.name, fkc.constraint_column_id",
s = schema.replace('\'', "''"),
t = table.replace('\'', "''")
);
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| ForeignKeyInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
column: row.get::<&str, _>(1).unwrap_or("").to_string(),
ref_schema: Some(row.get::<&str, _>(2).unwrap_or("").to_string()),
ref_table: row.get::<&str, _>(3).unwrap_or("").to_string(),
ref_column: row.get::<&str, _>(4).unwrap_or("").to_string(),
on_update: sqlserver_referential_action(row.get::<i32, _>(6).unwrap_or(0)),
on_delete: sqlserver_referential_action(row.get::<i32, _>(5).unwrap_or(0)),
})
.collect())
)
}
/// `sys.foreign_keys.delete_referential_action` and `update_referential_action`
/// are `tinyint`. tiberius picks the integer width from the received byte length
/// rather than the declared type, so those columns arrive as `ColumnData::U8` and
/// `row.get::<i32, _>` panics on the failed conversion (the whole process aborts
/// under `panic = "abort"`). Read through the narrow integer widths instead.
fn sqlserver_referential_action_from_row(row: &Row, index: usize) -> Option<String> {
sqlserver_referential_action(sqlserver_narrow_i32(
row.try_get::<i32, _>(index),
row.try_get::<i16, _>(index),
row.try_get::<u8, _>(index),
))
}
/// Collapses the `int` / `smallint` / `tinyint` reads of one column into a single
/// value. A failed or null conversion falls through to the narrower widths, so
/// `tinyint` columns (decoded as `U8`) are read correctly instead of panicking.
fn sqlserver_narrow_i32(
as_i32: tiberius::Result<Option<i32>>,
as_i16: tiberius::Result<Option<i16>>,
as_u8: tiberius::Result<Option<u8>>,
) -> i32 {
as_i32
.ok()
.flatten()
.or_else(|| as_i16.ok().flatten().map(i32::from))
.or_else(|| as_u8.ok().flatten().map(i32::from))
.unwrap_or(0)
}
/// sys.foreign_keys referential actions: 0 = NO ACTION (default, omitted),
@@ -3806,17 +3839,17 @@ mod tests {
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_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_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,
sqlserver_done_trace_event, sqlserver_filter_definition_error, sqlserver_foreign_keys_sql,
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_narrow_i32, sqlserver_probe_explicit_alias,
sqlserver_query_messages, sqlserver_query_transport_for_engine_edition, sqlserver_query_transport_for_request,
sqlserver_referential_action, 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,
@@ -4658,6 +4691,71 @@ mod tests {
assert!(!sql.contains("STRING_AGG"));
}
#[test]
fn sqlserver_foreign_keys_sql_selects_referential_actions_and_escapes_names() {
let sql = sqlserver_foreign_keys_sql("d'bo", "t'able");
assert!(sql.contains("fk.delete_referential_action"));
assert!(sql.contains("fk.update_referential_action"));
assert!(sql.contains("sys.foreign_keys fk"));
assert!(sql.contains("OBJECT_ID('d''bo.t''able')"));
assert!(sql.contains("ORDER BY fk.name, fkc.constraint_column_id"));
}
#[test]
fn sqlserver_tinyint_referential_actions_decode_as_u8_not_i32() {
// sys.foreign_keys.delete_referential_action / update_referential_action
// are tinyint, and tiberius derives the integer width from the received
// byte length rather than the declared type, so the value arrives as
// ColumnData::U8.
let tinyint = ColumnData::U8(Some(1));
assert_eq!(<u8 as tiberius::FromSql>::from_sql(&tinyint).unwrap(), Some(1));
// row.get::<i32, _>() panics on this conversion error, which aborts the
// release build (`panic = "abort"`), so the reader must tolerate the
// narrow widths instead.
assert!(<i32 as tiberius::FromSql>::from_sql(&tinyint).is_err());
assert!(<i16 as tiberius::FromSql>::from_sql(&tinyint).is_err());
}
#[test]
fn sqlserver_narrow_i32_falls_back_to_tinyint_without_panicking() {
let conversion_error: tiberius::Result<Option<i32>> =
<i32 as tiberius::FromSql>::from_sql(&ColumnData::U8(Some(1)));
assert!(conversion_error.is_err());
// A tinyint CASCADE arrives as U8 while the wider reads fail; the value
// must survive instead of aborting the process.
let cascade: tiberius::Result<Option<i16>> = <i16 as tiberius::FromSql>::from_sql(&ColumnData::U8(Some(1)));
let as_u8: tiberius::Result<Option<u8>> = <u8 as tiberius::FromSql>::from_sql(&ColumnData::U8(Some(1)));
assert_eq!(sqlserver_narrow_i32(conversion_error, cascade, as_u8), 1);
}
#[test]
fn sqlserver_narrow_i32_prefers_wider_widths_and_defaults_to_zero() {
assert_eq!(sqlserver_narrow_i32(Ok(Some(2)), Ok(Some(9)), Ok(Some(9))), 2);
assert_eq!(sqlserver_narrow_i32(Ok(None), Ok(Some(3)), Ok(Some(9))), 3);
// Null or unreadable values fall back to 0, which maps to no action.
assert_eq!(sqlserver_narrow_i32(Ok(None), Ok(None), Ok(None)), 0);
assert_eq!(
sqlserver_narrow_i32(
Err(tiberius::error::Error::Conversion("boom".into())),
Err(tiberius::error::Error::Conversion("boom".into())),
Err(tiberius::error::Error::Conversion("boom".into()))
),
0
);
}
#[test]
fn sqlserver_referential_action_maps_tinyint_codes() {
assert_eq!(sqlserver_referential_action(0), None);
assert_eq!(sqlserver_referential_action(1).as_deref(), Some("CASCADE"));
assert_eq!(sqlserver_referential_action(2).as_deref(), Some("SET NULL"));
assert_eq!(sqlserver_referential_action(3).as_deref(), Some("SET DEFAULT"));
assert_eq!(sqlserver_referential_action(4), None);
}
#[test]
fn sqlserver_constraints_sql_unions_every_constraint_catalog() {
let sql = sqlserver_constraints_sql("d'bo", "t'able");