fix(sqlserver): preserve unqualified table schema for editing

This commit is contained in:
zipg authored and GitHub committed 2026-07-31 15:25:24 +08:00
1 parent ac6b53365b
commit e48902fa06
6 files changed
+167 -13

No files matched your search

@@ -1,6 +1,7 @@
package com.dbx.agent.sqlserverlegacy; package com.dbx.agent.sqlserverlegacy;
import com.dbx.agent.ConfiguredJdbcAgent; import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.ConnectParams; import com.dbx.agent.ConnectParams;
import com.dbx.agent.DdlBuilder; import com.dbx.agent.DdlBuilder;
import com.dbx.agent.ForeignKeyInfo; import com.dbx.agent.ForeignKeyInfo;
@@ -95,6 +96,47 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
}); });
} }
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return super.getColumns(metadataSchema(schema, table), table);
}
@Override
public List<IndexInfo> listIndexes(String schema, String table) {
return super.listIndexes(metadataSchema(schema, table), table);
}
@Override
public List<ForeignKeyInfo> 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 @Override
public String getTableDdl(String schema, String table) { public String getTableDdl(String schema, String table) {
List<IndexInfo> indexes; List<IndexInfo> indexes;
@@ -8,6 +8,17 @@ import java.security.Security;
import java.sql.SQLException; import java.sql.SQLException;
class SqlServerLegacyAgentTest { 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 @Test
void constructorRelaxesLegacyTlsPolicyBeforeDriverLoading() { void constructorRelaxesLegacyTlsPolicyBeforeDriverLoading() {
String key = "jdk.tls.disabledAlgorithms"; String key = "jdk.tls.disabledAlgorithms";
@@ -687,6 +687,76 @@ describe("queryStore hidden primary key editing", () => {
expect(tab.queryEditabilityReason).toBeUndefined(); 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 () => { it("appends only the missing part of a composite primary key", async () => {
getColumns.mockResolvedValue([ getColumns.mockResolvedValue([
{ name: "tenant_id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, { name: "tenant_id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
+12 -3
View File
@@ -2771,6 +2771,7 @@ export const useQueryStore = defineStore("query", () => {
source: EditableQuerySource; source: EditableQuerySource;
analysis: EditableQueryInfo; analysis: EditableQueryInfo;
request: TableMetadataRequest; request: TableMetadataRequest;
writeSchema?: string;
}; };
interface EditableQueryExecutionPreparation { interface EditableQueryExecutionPreparation {
@@ -2794,7 +2795,11 @@ export const useQueryStore = defineStore("query", () => {
// with a qualified source. // with a qualified source.
const qualifiedSourceDatabase = dbType === "sqlserver" ? source.catalog : connectionUsesDatabaseObjectTreeMode(conn) ? source.schema : undefined; const qualifiedSourceDatabase = dbType === "sqlserver" ? source.catalog : connectionUsesDatabaseObjectTreeMode(conn) ? source.schema : undefined;
const metadataDatabase = qualifiedSourceDatabase || executionDatabase || conn?.database || tab.database; 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 (!schema) {
if (dbType === "postgres" || dbType === "kwdb") schema = "public"; if (dbType === "postgres" || dbType === "kwdb") schema = "public";
else schema = ""; else schema = "";
@@ -2802,7 +2807,7 @@ export const useQueryStore = defineStore("query", () => {
// Oracle-family connection databases are service names, not schemas. When // Oracle-family connection databases are service names, not schemas. When
// the query does not qualify a schema, let the driver resolve the current // 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. // 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 metadataSchema = normalizeOracleLikeMetadataIdentifier(dbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || "";
const metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!; const metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!;
const metadataCatalog = normalizeOracleLikeMetadataIdentifier(dbType, source.catalog, source.catalogQuoted); const metadataCatalog = normalizeOracleLikeMetadataIdentifier(dbType, source.catalog, source.catalogQuoted);
@@ -2812,10 +2817,14 @@ export const useQueryStore = defineStore("query", () => {
schema: metadataSchema || undefined, schema: metadataSchema || undefined,
tableName: metadataTableName, 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; const knownTableType = tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined;
return { return {
source: metadataSource, source: metadataSource,
analysis: normalizeOracleLikeQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), analysis: normalizeOracleLikeQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName),
writeSchema,
request: { request: {
connectionId: tab.connectionId!, connectionId: tab.connectionId!,
database: metadataDatabase, database: metadataDatabase,
@@ -2836,7 +2845,7 @@ export const useQueryStore = defineStore("query", () => {
tableMeta: { tableMeta: {
catalog: target.request.catalog, catalog: target.request.catalog,
database: target.request.database, database: target.request.database,
schema: target.request.schema || undefined, schema: target.writeSchema,
tableName: target.request.tableName, tableName: target.request.tableName,
tableType: metadata.tableType, tableType: metadata.tableType,
columns: metadata.columns, columns: metadata.columns,
+29 -7
View File
@@ -1655,6 +1655,27 @@ fn sqlserver_schema_name_predicate(schema: &str, schema_name_expression: &str) -
format!("{schema_name_expression} = N'{}'", schema.replace('\'', "''")) 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 { fn escape_like_literal(value: &str) -> String {
value.replace('\\', "\\\\").replace('\'', "''").replace('%', "\\%").replace('_', "\\_").replace('[', "\\[") 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 { fn sqlserver_columns_sql(schema: &str, table: &str) -> String {
let t = table.replace('\'', "''"); 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 // COLUMNPROPERTY keeps hidden/generated flags separate and returns NULL on
// SQL Server versions that do not expose a newer property. // SQL Server versions that do not expose a newer property.
format!( format!(
@@ -1974,6 +1995,7 @@ fn sqlserver_indexes_sql_with_filter_definition(schema: &str, table: &str, inclu
} else { } else {
"CAST(NULL AS NVARCHAR(MAX)) AS filter_definition" "CAST(NULL AS NVARCHAR(MAX)) AS filter_definition"
}; };
let object_id = sqlserver_object_id_expression(schema, table);
format!( format!(
"SELECT i.name, \ "SELECT i.name, \
STUFF((SELECT ',' + c2.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 \ ep.value AS index_comment \
FROM sys.indexes i \ 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 \ 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", ORDER BY i.name",
s = schema.replace('\'', "''"),
t = table.replace('\'', "''")
) )
} }
@@ -2834,7 +2854,7 @@ mod tests {
assert!(!sql.contains("STRING_AGG")); assert!(!sql.contains("STRING_AGG"));
assert!(sql.contains("FOR XML PATH")); 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] #[test]
@@ -2928,7 +2948,7 @@ mod tests {
assert!(columns_sql.contains("s.name = N'd''bo'")); assert!(columns_sql.contains("s.name = N'd''bo'"));
assert!(columns_sql.contains("o.name = 't''able'")); assert!(columns_sql.contains("o.name = 't''able'"));
assert!(columns_sql.contains("sys.identity_columns")); 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] #[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')" "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_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] #[test]
+3 -3
View File
@@ -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(); const restoreStorage = installMemoryStorage();
setActivePinia(createPinia()); setActivePinia(createPinia());
const connectionStore = useConnectionStore(); 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); const tab = store.tabs.find((item) => item.id === tabId);
await waitFor(() => columnRequests.length > 0 && tab?.tableMeta?.tableName === "users"); await waitFor(() => columnRequests.length > 0 && tab?.tableMeta?.tableName === "users");
assert.deepEqual(analyzedSql, [baseSql]); assert.deepEqual(analyzedSql, [baseSql]);
assert.deepEqual(columnRequests, [{ schema: "dbo", table: "users" }]); assert.deepEqual(columnRequests, [{ schema: "", table: "users" }]);
assert.equal(tab?.tableMeta?.schema, "dbo"); assert.equal(tab?.tableMeta?.schema, undefined);
assert.equal(tab?.tableMeta?.columns[0]?.comment, "编号"); assert.equal(tab?.tableMeta?.columns[0]?.comment, "编号");
assert.equal(tab?.tableMeta?.columns[1]?.comment, "姓名"); assert.equal(tab?.tableMeta?.columns[1]?.comment, "姓名");