diff --git a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java index 7ec3e19b5..76a319acb 100644 --- a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java +++ b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java @@ -1,6 +1,7 @@ package com.dbx.agent.sqlserverlegacy; import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ColumnInfo; import com.dbx.agent.ConnectParams; import com.dbx.agent.DdlBuilder; import com.dbx.agent.ForeignKeyInfo; @@ -95,6 +96,47 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { }); } + @Override + public List getColumns(String schema, String table) { + return super.getColumns(metadataSchema(schema, table), table); + } + + @Override + public List listIndexes(String schema, String table) { + return super.listIndexes(metadataSchema(schema, table), table); + } + + @Override + public List listForeignKeys(String schema, String table) { + return super.listForeignKeys(metadataSchema(schema, table), table); + } + + private String metadataSchema(String schema, String table) { + if (schema != null && !schema.trim().isEmpty()) { + return schema; + } + return unchecked(() -> { + try (PreparedStatement statement = requireConnection().prepareStatement(unqualifiedObjectSchemaSql())) { + statement.setString(1, table); + try (ResultSet resultSet = statement.executeQuery()) { + return normalizeMetadataSchema(schema, resultSet.next() ? resultSet.getString("schema_name") : null); + } + } + }); + } + + static String unqualifiedObjectSchemaSql() { + return "SELECT COALESCE(OBJECT_SCHEMA_NAME(OBJECT_ID(QUOTENAME(?))), " + + "NULLIF(SCHEMA_NAME(), N''), N'dbo') AS schema_name"; + } + + static String normalizeMetadataSchema(String schema, String defaultSchema) { + if (schema != null && !schema.trim().isEmpty()) { + return schema; + } + return defaultSchema == null || defaultSchema.trim().isEmpty() ? "dbo" : defaultSchema; + } + @Override public String getTableDdl(String schema, String table) { List indexes; diff --git a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java index 15d78965d..bc3df8afb 100644 --- a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java +++ b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java @@ -8,6 +8,17 @@ import java.security.Security; import java.sql.SQLException; class SqlServerLegacyAgentTest { + @Test + void metadataSchemaKeepsExplicitSchemaAndResolvesDefault() { + Assertions.assertEquals("sales", SqlServerLegacyAgent.normalizeMetadataSchema("sales", "dbo")); + Assertions.assertEquals("tenant_owner", SqlServerLegacyAgent.normalizeMetadataSchema("", "tenant_owner")); + Assertions.assertEquals("dbo", SqlServerLegacyAgent.normalizeMetadataSchema(null, " ")); + Assertions.assertEquals( + "SELECT COALESCE(OBJECT_SCHEMA_NAME(OBJECT_ID(QUOTENAME(?))), NULLIF(SCHEMA_NAME(), N''), N'dbo') AS schema_name", + SqlServerLegacyAgent.unqualifiedObjectSchemaSql() + ); + } + @Test void constructorRelaxesLegacyTlsPolicyBeforeDriverLoading() { String key = "jdk.tls.disabledAlgorithms"; diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index 499077d7e..d5856f83f 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -687,6 +687,76 @@ describe("queryStore hidden primary key editing", () => { expect(tab.queryEditabilityReason).toBeUndefined(); }); + it("keeps SQL Server updates unqualified when the SELECT source is unqualified", async () => { + getConnectionConfig.mockReturnValue({ id: "sqlserver-1", name: "SQL Server 2008", db_type: "sqlserver", database: "cdc", query_timeout_secs: 30 }); + getColumns.mockResolvedValue([ + { name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "a4", data_type: "nvarchar(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]); + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "yb_ty_qtxx", + selectStar: true, + columns: [{ sourceName: undefined, star: true, resultName: "*", expression: "*" }], + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id", "a4"], + rows: [[1, "德谷胰岛素利拉鲁肽"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("sqlserver-1", "cdc", "Query", "query", "cdc"); + + await store.executeTabSql(tabId, "select * from yb_ty_qtxx where a4 like N'%德谷胰岛素利拉鲁%'"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.tableMeta).toBeDefined()); + expect(getColumns).toHaveBeenCalledWith("sqlserver-1", "cdc", "", "yb_ty_qtxx", undefined); + expect(tab.tableMeta?.database).toBe("cdc"); + expect(tab.tableMeta?.schema).toBeUndefined(); + }); + + it("preserves an explicitly qualified SQL Server update source", async () => { + getConnectionConfig.mockReturnValue({ id: "sqlserver-1", name: "SQL Server 2008", db_type: "sqlserver", database: "cdc", query_timeout_secs: 30 }); + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: "sales", + tableName: "yb_ty_qtxx", + selectStar: true, + columns: [{ sourceName: undefined, star: true, resultName: "*", expression: "*" }], + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id", "a4"], + rows: [[1, "德谷胰岛素利拉鲁肽"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("sqlserver-1", "cdc", "Query", "query", "cdc"); + + await store.executeTabSql(tabId, "select * from sales.yb_ty_qtxx"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.tableMeta).toBeDefined()); + expect(getColumns).toHaveBeenCalledWith("sqlserver-1", "cdc", "sales", "yb_ty_qtxx", undefined); + expect(tab.tableMeta?.database).toBe("cdc"); + expect(tab.tableMeta?.schema).toBe("sales"); + }); + it("appends only the missing part of a composite primary key", async () => { getColumns.mockResolvedValue([ { name: "tenant_id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index d4775e650..f6bf31ead 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2771,6 +2771,7 @@ export const useQueryStore = defineStore("query", () => { source: EditableQuerySource; analysis: EditableQueryInfo; request: TableMetadataRequest; + writeSchema?: string; }; interface EditableQueryExecutionPreparation { @@ -2794,7 +2795,11 @@ export const useQueryStore = defineStore("query", () => { // 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; + // SQL Server does not apply the query tab's selected schema to an + // unqualified object reference. Resolve metadata through the login's + // default schema (with the driver's dbo fallback) so metadata and writes + // target the same object as the original SELECT. + let schema = source.schema || (dbType === "sqlserver" ? "" : tab.schema); if (!schema) { if (dbType === "postgres" || dbType === "kwdb") schema = "public"; else schema = ""; @@ -2802,7 +2807,7 @@ export const useQueryStore = defineStore("query", () => { // Oracle-family connection databases are service names, not schemas. When // the query does not qualify a schema, let the driver resolve the current // login user's schema instead of looking up metadata under the service name. - const resolvedSchema = ORACLE_LIKE_METADATA_TYPES.has(dbType) && !schema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined); + const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(dbType) && !schema) ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined); const metadataSchema = normalizeOracleLikeMetadataIdentifier(dbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || ""; const metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!; const metadataCatalog = normalizeOracleLikeMetadataIdentifier(dbType, source.catalog, source.catalogQuoted); @@ -2812,10 +2817,14 @@ export const useQueryStore = defineStore("query", () => { schema: metadataSchema || undefined, tableName: metadataTableName, }; + // Keep SQL Server writes unqualified unless the SELECT source explicitly + // named a schema, so SELECT and UPDATE resolve the same object. + const writeSchema = dbType === "sqlserver" && !source.schema ? undefined : metadataSchema || undefined; const knownTableType = tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined; return { source: metadataSource, analysis: normalizeOracleLikeQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), + writeSchema, request: { connectionId: tab.connectionId!, database: metadataDatabase, @@ -2836,7 +2845,7 @@ export const useQueryStore = defineStore("query", () => { tableMeta: { catalog: target.request.catalog, database: target.request.database, - schema: target.request.schema || undefined, + schema: target.writeSchema, tableName: target.request.tableName, tableType: metadata.tableType, columns: metadata.columns, diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 80dac9eeb..75732b151 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -1655,6 +1655,27 @@ fn sqlserver_schema_name_predicate(schema: &str, schema_name_expression: &str) - format!("{schema_name_expression} = N'{}'", schema.replace('\'', "''")) } +fn sqlserver_object_id_expression(schema: &str, table: &str) -> String { + let table = table.replace('\'', "''"); + if schema.trim().is_empty() { + return format!("OBJECT_ID(QUOTENAME(N'{table}'))"); + } + + let schema = schema.replace('\'', "''"); + format!("OBJECT_ID(QUOTENAME(N'{schema}') + N'.' + QUOTENAME(N'{table}'))") +} + +fn sqlserver_object_schema_name_predicate(schema: &str, table: &str, schema_name_expression: &str) -> String { + if schema.trim().is_empty() { + return format!( + "{schema_name_expression} = OBJECT_SCHEMA_NAME({})", + sqlserver_object_id_expression(schema, table) + ); + } + + sqlserver_schema_name_predicate(schema, schema_name_expression) +} + fn escape_like_literal(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "''").replace('%', "\\%").replace('_', "\\_").replace('[', "\\[") } @@ -1870,7 +1891,7 @@ fn sqlserver_column_metadata_from_row(row: &Row) -> SqlServerColumnMetadata { fn sqlserver_columns_sql(schema: &str, table: &str) -> String { let t = table.replace('\'', "''"); - let schema_filter = sqlserver_schema_name_predicate(schema, "s.name"); + let schema_filter = sqlserver_object_schema_name_predicate(schema, table, "s.name"); // COLUMNPROPERTY keeps hidden/generated flags separate and returns NULL on // SQL Server versions that do not expose a newer property. format!( @@ -1974,6 +1995,7 @@ fn sqlserver_indexes_sql_with_filter_definition(schema: &str, table: &str, inclu } else { "CAST(NULL AS NVARCHAR(MAX)) AS filter_definition" }; + let object_id = sqlserver_object_id_expression(schema, table); format!( "SELECT i.name, \ STUFF((SELECT ',' + c2.name \ @@ -1993,10 +2015,8 @@ fn sqlserver_indexes_sql_with_filter_definition(schema: &str, table: &str, inclu ep.value AS index_comment \ FROM sys.indexes i \ OUTER APPLY (SELECT CAST(ep.value AS NVARCHAR(MAX)) AS value FROM sys.extended_properties ep WHERE ep.major_id = i.object_id AND ep.minor_id = i.index_id AND ep.name = N'MS_Description' AND ep.class = 7) ep \ - WHERE i.object_id = OBJECT_ID('{s}.{t}') AND i.name IS NOT NULL \ + WHERE i.object_id = {object_id} AND i.name IS NOT NULL \ ORDER BY i.name", - s = schema.replace('\'', "''"), - t = table.replace('\'', "''") ) } @@ -2834,7 +2854,7 @@ mod tests { assert!(!sql.contains("STRING_AGG")); assert!(sql.contains("FOR XML PATH")); - assert!(sql.contains("OBJECT_ID('dbo.DF_Rule')")); + assert!(sql.contains("OBJECT_ID(QUOTENAME(N'dbo') + N'.' + QUOTENAME(N'DF_Rule'))")); } #[test] @@ -2928,7 +2948,7 @@ mod tests { assert!(columns_sql.contains("s.name = N'd''bo'")); assert!(columns_sql.contains("o.name = 't''able'")); assert!(columns_sql.contains("sys.identity_columns")); - assert!(indexes_sql.contains("OBJECT_ID('d''bo.t''able')")); + assert!(indexes_sql.contains("OBJECT_ID(QUOTENAME(N'd''bo') + N'.' + QUOTENAME(N't''able'))")); } #[test] @@ -2940,7 +2960,9 @@ mod tests { "s.name = COALESCE((SELECT default_schema.name FROM sys.schemas default_schema WHERE default_schema.name = SCHEMA_NAME()), N'dbo')" ); assert!(sqlserver_list_tables_sql("", None, None, None).contains(&predicate)); - assert!(sqlserver_columns_sql("\t", "orders").contains(&predicate)); + assert!(sqlserver_columns_sql("\t", "orders") + .contains("s.name = OBJECT_SCHEMA_NAME(OBJECT_ID(QUOTENAME(N'orders')))"),); + assert!(sqlserver_indexes_sql("", "orders").contains("OBJECT_ID(QUOTENAME(N'orders'))")); } #[test] diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index cb3c6bed2..53c146afe 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -2118,7 +2118,7 @@ test("keeps joined query read-only when multiple source tables are writable cand } }); -test("uses dbo as SQL Server metadata schema and keeps sorted query results editable", async () => { +test("resolves unqualified SQL Server metadata through the default schema and keeps sorted query results editable", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); const connectionStore = useConnectionStore(); @@ -2217,8 +2217,8 @@ test("uses dbo as SQL Server metadata schema and keeps sorted query results edit const tab = store.tabs.find((item) => item.id === tabId); await waitFor(() => columnRequests.length > 0 && tab?.tableMeta?.tableName === "users"); assert.deepEqual(analyzedSql, [baseSql]); - assert.deepEqual(columnRequests, [{ schema: "dbo", table: "users" }]); - assert.equal(tab?.tableMeta?.schema, "dbo"); + assert.deepEqual(columnRequests, [{ schema: "", table: "users" }]); + assert.equal(tab?.tableMeta?.schema, undefined); assert.equal(tab?.tableMeta?.columns[0]?.comment, "编号"); assert.equal(tab?.tableMeta?.columns[1]?.comment, "姓名");