fix(sqlserver): preserve database-qualified editing targets

This commit is contained in:
guoyongchang 2026-07-30 20:55:14 +08:00 committed by GitHub
parent 9d7f87feb8
commit b89e0079a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 132 additions and 15 deletions

View File

@ -2783,8 +2783,10 @@ export const useQueryStore = defineStore("query", () => {
function resolveEditableSourceMetadataTarget(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string): EditableSourceMetadataTarget {
// Metadata must resolve in the same namespace as the query execution. An
// empty query-tab database still executes in the connection's default DB,
// while database-tree dialects may override it with a qualified source.
const metadataDatabase = (connectionUsesDatabaseObjectTreeMode(conn) ? source.schema : undefined) || executionDatabase || conn?.database || tab.database;
// while database-tree dialects and SQL Server 3-part names may override it
// with a qualified source.
const qualifiedSourceDatabase = dbType === "sqlserver" ? source.catalog : connectionUsesDatabaseObjectTreeMode(conn) ? source.schema : undefined;
const metadataDatabase = qualifiedSourceDatabase || executionDatabase || conn?.database || tab.database;
let schema = source.schema || tab.schema;
if (!schema) {
if (dbType === "postgres" || dbType === "kwdb") schema = "public";

View File

@ -4183,6 +4183,35 @@ mod tests {
assert_eq!(result.statements, vec!["UPDATE [dbo].[users] SET [UserId] = 144847503924137986 WHERE [Id] = 1;"]);
}
#[test]
fn prepares_sqlserver_cross_database_update() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::SqlServer),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: Some("BarDB".to_string()),
database: Some("BarDB".to_string()),
schema: Some("dbo".to_string()),
table_name: "TUser".to_string(),
primary_keys: vec!["ID".to_string()],
columns: Some(vec![column("ID", "int", false, None), column("UserId", "bigint", false, None)]),
},
columns: vec!["ID".to_string(), "UserId".to_string()],
source_columns: None,
rows: vec![vec![json!(1), json!(10279)]],
dirty_rows: vec![(0, vec![(1, json!(10280))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE [BarDB].[dbo].[TUser] SET [UserId] = 10280 WHERE [ID] = 1;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE [BarDB].[dbo].[TUser] SET [UserId] = 10279 WHERE [ID] = 1 AND [UserId] = 10280;"]
);
}
#[test]
fn prepares_kingbase_update_when_source_primary_key_case_differs() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {

View File

@ -71,17 +71,12 @@ pub fn qualified_table_name(database_type: Option<DatabaseType>, schema: Option<
quote_table_identifier(database_type, table_name)
}
/// Like `qualified_table_name`, but prefixes a Doris/StarRocks external
/// catalog (`<catalog>.<database>.<table>`) when `catalog` is present,
/// non-empty, and not the engine's `internal` catalog. The middle segment is
/// the database — Doris/StarRocks have no separate schema concept, so
/// `schema` is only used when a caller passes it that way; otherwise `database`
/// fills the middle slot. When neither is set the name degrades to the 2-part
/// `<catalog>.<table>` form. The `internal` guard is defensive — built-in
/// catalog tables never carry a catalog in the first place (the sidebar routes
/// them through the standard path), so this only ever prefixes genuine
/// external catalogs. Other engines ignore `catalog` (they have no 3-part
/// catalog naming).
/// Like `qualified_table_name`, but also supports 3-part names for SQL Server
/// (`<database>.<schema>.<table>`) and Doris/StarRocks external catalogs
/// (`<catalog>.<database>.<table>`). SQL parsing stores the first segment of a
/// 3-part source in `catalog`; for SQL Server that segment is its database.
/// Doris/StarRocks use `schema` as the middle segment when present, otherwise
/// `database`, and ignore their built-in `internal` catalog.
pub fn qualified_table_name_with_catalog(
database_type: Option<DatabaseType>,
catalog: Option<&str>,
@ -89,9 +84,13 @@ pub fn qualified_table_name_with_catalog(
database: Option<&str>,
table_name: &str,
) -> String {
let catalog = catalog.map(str::trim).filter(|catalog| !catalog.is_empty() && *catalog != "internal");
let catalog = catalog.map(str::trim).filter(|catalog| !catalog.is_empty());
match (catalog, database_type) {
(Some(catalog), Some(DatabaseType::Doris | DatabaseType::StarRocks)) => {
(Some(database), Some(DatabaseType::SqlServer)) => {
let table = qualified_table_name(database_type, schema, table_name);
format!("{}.{}", quote_table_identifier(database_type, database), table)
}
(Some(catalog), Some(DatabaseType::Doris | DatabaseType::StarRocks)) if catalog != "internal" => {
let middle = schema
.map(str::trim)
.filter(|schema| !schema.is_empty())

View File

@ -2034,6 +2034,93 @@ test("uses dbo as SQL Server metadata schema when query omits schema", async ()
}
});
test("uses the qualified SQL Server database as the editable result target", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
const sql = "select * from BarDB.dbo.TUser AS ts where UserId = 10279";
const columnRequests: Array<{ database: string | null; schema: string | null; table: string | null; catalog: string | null }> = [];
connectionStore.addEphemeralConnection(sqlServerConn("sqlserver-cross-database"));
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/execute-multi") {
return new Response(
JSON.stringify([
{
columns: ["ID", "UserId"],
rows: [[1, 10279]],
affected_rows: 0,
execution_time_ms: 1,
},
]),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "/api/query/analyze-editability") {
return new Response(
JSON.stringify({
editable: true,
analysis: {
catalog: "BarDB",
catalogQuoted: false,
schema: "dbo",
schemaQuoted: false,
tableName: "TUser",
tableNameQuoted: false,
tableAlias: "ts",
selectStar: true,
columns: [],
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url.startsWith("/api/schema/columns?")) {
const params = new URL(url, "http://localhost").searchParams;
columnRequests.push({
database: params.get("database"),
schema: params.get("schema"),
table: params.get("table"),
catalog: params.get("catalog"),
});
return new Response(
JSON.stringify([
{ name: "ID", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "UserId", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
]),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("unexpected request", { status: 500 });
});
try {
const tabId = store.createTab("sqlserver-cross-database", "FooDB", "Query 1", "query");
await store.executeTabSql(tabId, sql);
const tab = store.tabs.find((item) => item.id === tabId);
await waitFor(() => columnRequests.length > 0 && tab?.tableMeta?.tableName === "TUser");
assert.deepEqual(columnRequests, [{ database: "BarDB", schema: "dbo", table: "TUser", catalog: "BarDB" }]);
assert.equal(tab?.tableMeta?.database, "BarDB");
assert.equal(tab?.tableMeta?.catalog, "BarDB");
assert.equal(tab?.tableMeta?.schema, "dbo");
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("evicting cached tab results releases multi-result payloads and sessions", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());