fix(sqlserver): align schema tree and creation

This commit is contained in:
t8y2
2026-06-25 20:37:23 +08:00
parent 380d497fd3
commit 0cc0b84c95
5 changed files with 123 additions and 128 deletions
+3 -36
View File
@@ -1,42 +1,11 @@
import type { ObjectInfo, TreeNode } from "@/types/database";
import type { TreeNode } from "@/types/database";
import { sortSidebarNames } from "@/lib/databaseTree";
import { buildGroupedObjectTreeNodes, buildObjectGroupPlaceholderNodes, buildSimpleObjectTreeNodes, type DatabaseObjectTreeKind } from "@/lib/tableTree";
export const SQLSERVER_DEFAULT_SCHEMA = "dbo";
function isDefaultSchema(schema: string): boolean {
return schema.toLowerCase() === SQLSERVER_DEFAULT_SCHEMA;
}
export function buildSqlServerDatabaseTreeNodes(connectionId: string, database: string, schemas: string[], defaultSchemaObjects: ObjectInfo[], options: { lazyObjectTypes?: DatabaseObjectTreeKind[]; simpleObjectDisplay?: boolean } = {}): TreeNode[] {
export function buildSqlServerDatabaseTreeNodes(connectionId: string, database: string, schemas: string[]): TreeNode[] {
const databaseNodeId = `${connectionId}:${database}`;
const defaultSchema = schemas.find(isDefaultSchema) || SQLSERVER_DEFAULT_SCHEMA;
const defaultObjectNodes = options.lazyObjectTypes
? buildObjectGroupPlaceholderNodes({
nodeId: databaseNodeId,
connectionId,
database,
schema: defaultSchema,
objectTypes: options.lazyObjectTypes,
})
: options.simpleObjectDisplay
? buildSimpleObjectTreeNodes({
nodeId: databaseNodeId,
connectionId,
database,
schema: defaultSchema,
objects: defaultSchemaObjects,
})
: buildGroupedObjectTreeNodes({
nodeId: databaseNodeId,
connectionId,
database,
schema: defaultSchema,
objects: defaultSchemaObjects,
});
const schemaNodes = sortSidebarNames(schemas.filter((schema) => !isDefaultSchema(schema))).map((schema) => ({
return sortSidebarNames(schemas).map((schema) => ({
id: `${databaseNodeId}:${schema}`,
label: schema,
type: "schema" as const,
@@ -46,6 +15,4 @@ export function buildSqlServerDatabaseTreeNodes(connectionId: string, database:
isExpanded: false,
children: [],
}));
return [...defaultObjectNodes, ...schemaNodes];
}
+4 -9
View File
@@ -24,7 +24,7 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
import { isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/databaseTree";
import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree";
import { buildSqlServerDatabaseTreeNodes } from "@/lib/sqlServerTree";
import { findDatabaseTreeNode } from "@/lib/treeRefreshTarget";
import { shouldMarkDisconnected } from "@/lib/connectionHealth";
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connectionAttemptTimeout";
@@ -1658,7 +1658,7 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const cacheKey = schemaCacheKey(connectionId, database, simpleObjectDisplay ? "sqlserver-objects-simple-v3" : "sqlserver-objects-grouped-v3");
const cacheKey = schemaCacheKey(connectionId, database, simpleObjectDisplay ? "sqlserver-schemas-simple-v4" : "sqlserver-schemas-grouped-v4");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
@@ -1669,11 +1669,7 @@ export const useConnectionStore = defineStore("connection", () => {
const config = getConfig(connectionId);
const schemas = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), config, database);
const defaultSchemaObjects = simpleObjectDisplay ? await api.listObjects(connectionId, database, SQLSERVER_DEFAULT_SCHEMA) : [];
const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas, defaultSchemaObjects, {
lazyObjectTypes: simpleObjectDisplay ? undefined : supportedSidebarObjectTypes(config),
simpleObjectDisplay,
});
const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas);
setChildren(node, children);
await savePersistedTreeChildren(cacheKey, children);
node.isExpanded = true;
@@ -2315,8 +2311,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string) {
const config = getConfig(connectionId);
const shouldRefreshSchemaNode = schema && !(config?.db_type === "sqlserver" && schema.toLowerCase() === "dbo");
const shouldRefreshSchemaNode = !!schema;
const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null;
if (node) {
await refreshTreeNode(node);
+73 -24
View File
@@ -895,26 +895,23 @@ fn linked_i32(row: &tiberius::Row, index: usize) -> Option<i32> {
}
pub async fn list_schemas(client: &mut SqlServerClient) -> Result<Vec<String>, String> {
let stream = client
.query(
"SELECT s.name \
FROM sys.schemas s \
WHERE s.name NOT IN ('guest','INFORMATION_SCHEMA','sys') \
AND EXISTS ( \
SELECT 1 FROM sys.objects o \
WHERE o.schema_id = s.schema_id \
AND o.type IN ('U','V') \
AND o.is_ms_shipped = 0 \
) \
ORDER BY CASE WHEN s.name = 'dbo' THEN 0 ELSE 1 END, s.name",
&[],
)
.await
.map_err(|e| e.to_string())?;
let sql = sqlserver_list_schemas_sql();
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| row.get::<&str, _>(0).unwrap_or("").to_string()).collect())
}
fn sqlserver_list_schemas_sql() -> String {
let excluded_schemas =
sqlserver_hidden_schema_names().iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(",");
format!(
"SELECT s.name \
FROM sys.schemas s \
WHERE s.name NOT IN ({excluded_schemas}) \
ORDER BY CASE WHEN s.name = 'dbo' THEN 0 ELSE 1 END, s.name"
)
}
pub async fn list_tables(
client: &mut SqlServerClient,
schema: &str,
@@ -1051,6 +1048,7 @@ fn sqlserver_completion_assistant_sql(request: &crate::types::CompletionAssistan
type_ids.extend(["'FN'", "'IF'", "'TF'", "'FS'", "'FT'"]);
}
let object_like = sqlserver_completion_object_search_clause(request, &like_pattern);
let object_visibility = sqlserver_visible_object_predicate();
queries.push(format!(
"SELECT TOP ({limit}) o.name, s.name AS schema_name, \
CASE o.type WHEN 'U' THEN 'TABLE' WHEN 'V' THEN 'VIEW' WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' WHEN 'FS' THEN 'FUNCTION' WHEN 'FT' THEN 'FUNCTION' ELSE o.type_desc END AS object_type, \
@@ -1058,7 +1056,7 @@ fn sqlserver_completion_assistant_sql(request: &crate::types::CompletionAssistan
FROM sys.objects o \
JOIN sys.schemas s ON s.schema_id = o.schema_id \
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 o.type IN ({}) AND o.is_ms_shipped = 0 {schema_filter} {object_like}",
WHERE o.type IN ({}) AND {object_visibility} {schema_filter} {object_like}",
type_ids.join(",")
));
}
@@ -1070,12 +1068,13 @@ fn sqlserver_completion_assistant_sql(request: &crate::types::CompletionAssistan
.filter(|table| !table.trim().is_empty())
.map(|table| format!(" AND o.name = '{}' ", table.replace('\'', "''")))
.unwrap_or_default();
let object_visibility = sqlserver_visible_object_predicate();
queries.push(format!(
"SELECT TOP ({limit}) c.name, s.name AS schema_name, 'COLUMN' AS object_type, s.name AS parent_schema, o.name AS parent_name, CAST(NULL AS NVARCHAR(MAX)) AS object_comment, TYPE_NAME(c.user_type_id) AS data_type \
FROM sys.columns c \
JOIN sys.objects o ON o.object_id = c.object_id \
JOIN sys.schemas s ON s.schema_id = o.schema_id \
WHERE o.type IN ('U','V') AND o.is_ms_shipped = 0 {schema_filter} {parent_table_filter} {column_like}"
WHERE o.type IN ('U','V') AND {object_visibility} {schema_filter} {parent_table_filter} {column_like}"
));
}
@@ -1138,8 +1137,9 @@ fn sqlserver_list_tables_sql(
JOIN sys.schemas s ON s.schema_id = o.schema_id \
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";
let object_visibility = sqlserver_visible_object_predicate();
let base_where =
format!("WHERE s.name = '{schema_escaped}' AND o.type IN ('U','V') AND o.is_ms_shipped = 0 {filter_clause}");
format!("WHERE s.name = '{schema_escaped}' AND o.type IN ('U','V') AND {object_visibility} {filter_clause}");
let order_by = "ORDER BY o.name";
// Use SELECT TOP for broad SQL Server version compatibility.
@@ -1167,6 +1167,27 @@ fn escape_like_literal(value: &str) -> String {
value.replace('\\', "\\\\").replace('\'', "''").replace('%', "\\%").replace('_', "\\_").replace('[', "\\[")
}
fn sqlserver_visible_object_predicate() -> &'static str {
"(o.is_ms_shipped = 0 OR s.name = 'cdc')"
}
fn sqlserver_hidden_schema_names() -> &'static [&'static str] {
&[
"guest",
"INFORMATION_SCHEMA",
"sys",
"db_owner",
"db_accessadmin",
"db_securityadmin",
"db_ddladmin",
"db_backupoperator",
"db_datareader",
"db_datawriter",
"db_denydatareader",
"db_denydatawriter",
]
}
pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<Vec<crate::types::ObjectInfo>, String> {
let sql = sqlserver_list_objects_sql(schema);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
@@ -1188,6 +1209,7 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<
fn sqlserver_list_objects_sql(schema: &str) -> String {
let s = schema.replace('\'', "''");
let object_visibility = sqlserver_visible_object_predicate();
format!(
"SELECT o.name, \
CASE o.type \
@@ -1209,7 +1231,7 @@ fn sqlserver_list_objects_sql(schema: &str) -> String {
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 \
AND {object_visibility} \
ORDER BY CASE o.type \
WHEN 'U' THEN 0 \
WHEN 'V' THEN 1 \
@@ -1224,6 +1246,7 @@ pub async fn list_object_statistics(
schema: &str,
) -> Result<Vec<ObjectStatistics>, String> {
let s = schema.replace('\'', "''");
let object_visibility = sqlserver_visible_object_predicate();
let sql = format!(
"SELECT o.name, \
SUM(CASE WHEN ps.index_id IN (0, 1) THEN ps.row_count ELSE 0 END) AS estimated_rows, \
@@ -1231,7 +1254,7 @@ pub async fn list_object_statistics(
FROM sys.objects o \
JOIN sys.schemas s ON s.schema_id = o.schema_id \
JOIN sys.dm_db_partition_stats ps ON ps.object_id = o.object_id \
WHERE s.name = '{s}' AND o.type = 'U' AND o.is_ms_shipped = 0 \
WHERE s.name = '{s}' AND o.type = 'U' AND {object_visibility} \
GROUP BY o.object_id, o.name \
ORDER BY o.name"
);
@@ -1626,6 +1649,10 @@ fn is_transaction_control(sql: &str) -> bool {
fn requires_simple_query_batch(sql: &str) -> bool {
let tokens = first_sql_tokens(sql, 4);
if tokens.len() >= 2 && tokens[0].eq_ignore_ascii_case("CREATE") && tokens[1].eq_ignore_ascii_case("SCHEMA") {
return true;
}
if tokens.len() >= 4
&& tokens[0].eq_ignore_ascii_case("CREATE")
&& tokens[1].eq_ignore_ascii_case("OR")
@@ -1688,9 +1715,9 @@ mod tests {
use super::{
build_spatial_safe_sqlserver_query, is_sqlserver_spatial_column, requires_simple_query_batch,
sqlserver_batch_can_use_execute, sqlserver_cell_to_json, sqlserver_columns_sql,
sqlserver_completion_assistant_sql, sqlserver_dml_output_returns_rows, sqlserver_indexes_sql,
sqlserver_list_objects_sql, sqlserver_list_tables_sql, sqlserver_table_comment_sql, SqlServerDescribedColumn,
SqlServerResultSet,
sqlserver_completion_assistant_sql, sqlserver_dml_output_returns_rows, sqlserver_hidden_schema_names,
sqlserver_indexes_sql, sqlserver_list_objects_sql, sqlserver_list_schemas_sql, sqlserver_list_tables_sql,
sqlserver_table_comment_sql, sqlserver_visible_object_predicate, SqlServerDescribedColumn, SqlServerResultSet,
};
use crate::types::{CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest};
use chrono::NaiveDate;
@@ -1731,6 +1758,7 @@ mod tests {
#[test]
fn sqlserver_module_definitions_require_simple_query_batch() {
assert!(requires_simple_query_batch("CREATE SCHEMA [analytics];"));
assert!(requires_simple_query_batch("CREATE FUNCTION dbo.fn_demo() RETURNS INT AS BEGIN RETURN 1; END;"));
assert!(requires_simple_query_batch("ALTER PROCEDURE dbo.usp_demo AS SELECT 1;"));
assert!(requires_simple_query_batch("CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;"));
@@ -1741,6 +1769,7 @@ mod tests {
#[test]
fn sqlserver_regular_ddl_can_use_execute() {
assert!(!sqlserver_batch_can_use_execute("CREATE SCHEMA [analytics];"));
assert!(!requires_simple_query_batch("ALTER TABLE dbo.t ADD name NVARCHAR(20);"));
assert!(!requires_simple_query_batch("CREATE TABLE dbo.t(id INT);"));
assert!(!requires_simple_query_batch("UPDATE dbo.t SET id = 1;"));
@@ -1832,6 +1861,26 @@ mod tests {
assert!(sql.contains("modify_date"));
}
#[test]
fn sqlserver_metadata_allows_cdc_system_shipped_objects() {
let predicate = sqlserver_visible_object_predicate();
assert_eq!(predicate, "(o.is_ms_shipped = 0 OR s.name = 'cdc')");
assert!(sqlserver_list_tables_sql("cdc", None, Some(200), None).contains(predicate));
assert!(sqlserver_list_objects_sql("cdc").contains(predicate));
}
#[test]
fn sqlserver_list_schemas_includes_empty_user_schemas() {
let sql = sqlserver_list_schemas_sql();
assert!(!sql.contains("sys.objects"));
assert!(sql.contains("s.name NOT IN"));
assert!(sql.contains("'db_owner'"));
assert!(sql.contains("'db_datareader'"));
assert!(sqlserver_hidden_schema_names().contains(&"sys"));
}
#[test]
fn sqlserver_list_tables_filter_is_case_insensitive() {
let sql = sqlserver_list_tables_sql("dbo", Some("temp"), Some(200), None);
@@ -5,6 +5,37 @@ use dbx_core::storage::Storage;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[tokio::test]
#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"]
async fn live_sqlserver_execute_query_creates_schema() {
let database = std::env::var("DBX_LIVE_SQLSERVER_DATABASE").unwrap_or_else(|_| "tempdb".to_string());
let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_SQLSERVER_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(1433);
let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string());
let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD");
let mut client =
dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), Duration::from_secs(10))
.await
.expect("connect SQL Server");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let schema = format!("dbx_schema_{suffix}");
let create = format!("CREATE SCHEMA [{schema}];");
let verify = format!("SELECT SCHEMA_ID(N'{schema}') AS schema_id;");
let cleanup = format!("DROP SCHEMA [{schema}];");
let result = dbx_core::db::sqlserver::execute_query(&mut client, &create).await;
let verify_result = dbx_core::db::sqlserver::execute_query(&mut client, &verify).await;
let schemas = dbx_core::db::sqlserver::list_schemas(&mut client).await;
let _ = dbx_core::db::sqlserver::execute_query(&mut client, &cleanup).await;
result.expect("create schema through execute_query");
let verify_result = verify_result.expect("verify created schema");
assert_eq!(verify_result.rows.len(), 1);
assert!(verify_result.rows[0][0].as_i64().is_some(), "schema_id row={:?}", verify_result.rows[0]);
assert!(schemas.expect("list schemas").contains(&schema));
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"]
async fn live_sqlserver_stream_first_result_set_exports_cte_query_rows() {
+12 -59
View File
@@ -1,82 +1,35 @@
import { test } from "vitest";
import assert from "node:assert/strict";
import { buildSqlServerDatabaseTreeNodes } from "../../apps/desktop/src/lib/sqlServerTree.ts";
import type { ObjectInfo } from "../../apps/desktop/src/types/database.ts";
function obj(name: string, objectType = "TABLE"): ObjectInfo {
return {
name,
object_type: objectType,
};
}
test("SQL Server database tree groups dbo objects and keeps non-default schemas", () => {
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "zeta", "sales"], [obj("customers"), obj("customer_view", "VIEW"), obj("get_total", "FUNCTION")]);
test("SQL Server database tree shows schemas before objects", () => {
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "zeta", "sales"]);
const topLevel = nodes.map((n) => ({ id: n.id, label: n.label, type: n.type }));
assert.deepEqual(topLevel, [
{ id: "conn:app:__tables", label: "tree.tables", type: "group-tables" },
{ id: "conn:app:__views", label: "tree.views", type: "group-views" },
{ id: "conn:app:__functions", label: "tree.functions", type: "group-functions" },
{ id: "conn:app:dbo", label: "dbo", type: "schema" },
{ id: "conn:app:sales", label: "sales", type: "schema" },
{ id: "conn:app:zeta", label: "zeta", type: "schema" },
]);
});
const tableGroup = nodes.find((n) => n.type === "group-tables");
assert.deepEqual(
tableGroup?.children?.map((c) => c.label),
["customers"],
);
test("SQL Server database tree keeps dbo when no other schemas exist", () => {
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo"]);
const functionGroup = nodes.find((n) => n.type === "group-functions");
assert.deepEqual(
functionGroup?.children?.map((c) => c.label),
["get_total"],
nodes.map((node) => ({ id: node.id, label: node.label, type: node.type })),
[{ id: "conn:app:dbo", label: "dbo", type: "schema" }],
);
});
test("SQL Server database tree shows only schemas when dbo has no objects", () => {
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "archive", "beta"], []);
test("SQL Server database tree includes cdc schema", () => {
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "cdc"]);
assert.deepEqual(
nodes.map((node) => ({ id: node.id, label: node.label, type: node.type })),
[
{ id: "conn:app:archive", label: "archive", type: "schema" },
{ id: "conn:app:beta", label: "beta", type: "schema" },
],
);
});
test("SQL Server database tree groups procedures alongside tables", () => {
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo"], [obj("orders"), obj("sp_refresh", "PROCEDURE")]);
assert.deepEqual(
nodes.map((n) => ({ label: n.label, type: n.type })),
[
{ label: "tree.tables", type: "group-tables" },
{ label: "tree.procedures", type: "group-procedures" },
],
);
});
test("SQL Server database tree can render lazy object groups before metadata is loaded", () => {
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "sales"], [], {
lazyObjectTypes: ["TABLE", "VIEW", "PROCEDURE", "FUNCTION"],
});
assert.deepEqual(
nodes.map((n) => ({ label: n.label, type: n.type, count: n.objectCount, children: n.children })),
[
{ label: "tree.tables", type: "group-tables", count: undefined, children: [] },
{ label: "tree.views", type: "group-views", count: undefined, children: [] },
{ label: "tree.procedures", type: "group-procedures", count: undefined, children: [] },
{ label: "tree.functions", type: "group-functions", count: undefined, children: [] },
{
label: "sales",
type: "schema",
count: undefined,
children: [],
},
{ id: "conn:app:cdc", label: "cdc", type: "schema" },
{ id: "conn:app:dbo", label: "dbo", type: "schema" },
],
);
});