diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 04d3cd3be..e559ca6b4 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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"; diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 50dbdf8bf..d50b7636c 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -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 { diff --git a/crates/dbx-core/src/sql_dialect/identifiers.rs b/crates/dbx-core/src/sql_dialect/identifiers.rs index ef16aad74..c8c4441bf 100644 --- a/crates/dbx-core/src/sql_dialect/identifiers.rs +++ b/crates/dbx-core/src/sql_dialect/identifiers.rs @@ -71,17 +71,12 @@ pub fn qualified_table_name(database_type: Option, schema: Option< quote_table_identifier(database_type, table_name) } -/// Like `qualified_table_name`, but prefixes a Doris/StarRocks external -/// catalog (`..`) 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 -/// `.
` 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 +/// (`..
`) and Doris/StarRocks external catalogs +/// (`..
`). 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, 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()) diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index b32146d94..837b80743 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -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());