fix(mysql): preserve unqualified table refresh targets

This commit is contained in:
t8y2 2026-08-02 08:37:27 +08:00
parent 86499d5ffb
commit e16d1b66e5
No known key found for this signature in database
4 changed files with 92 additions and 6 deletions

View File

@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn(),
executeTabSql: vi.fn(),
loadTableMetadata: vi.fn(),
buildTableSelectSql: vi.fn(),
setErrorResult: vi.fn(),
cancelTabExecution: vi.fn(),
}));
@ -81,7 +82,7 @@ vi.mock("@/stores/settingsStore", () => ({
vi.mock("@/lib/database/jdbcDialect", () => ({
effectiveDatabaseTypeForConnection: () => mocks.databaseType,
connectionObjectTreeNodeSchema: (_config: unknown, _database: string, schema?: string) => schema,
connectionObjectTreeQuerySchema: (_config: unknown, _database: string, schema?: string) => schema ?? "",
connectionObjectTreeQuerySchema: (_config: unknown, database: string, schema?: string) => schema ?? database,
}));
vi.mock("@/lib/metadata/tableMetadataCache", async (importOriginal) => {
@ -97,7 +98,7 @@ vi.mock("@/lib/common/utils", () => ({ uuid: () => "open-data-id" }));
vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn(), isDebugLoggingEnabled: () => false }));
// dataTabOpenPolicy 使用真实实现,覆盖设置开关对应的复用范围
vi.mock("@/lib/sidebar/treeNodeContext", () => ({ hasTreeNodeDatabaseContext: () => true }));
vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: async () => "SELECT * FROM users" }));
vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: mocks.buildTableSelectSql }));
vi.mock("@/lib/table/tableEditing", () => ({ usesSyntheticRowIdKey: () => false }));
vi.mock("@/lib/table/tableOpenPageLimit", () => ({ tableOpenPageLimit: () => 100 }));
vi.mock("@/lib/tabs/dataTabActivation", () => ({ canActivateExistingDataTableTab: () => false }));
@ -112,6 +113,14 @@ const tableNode: TreeNode = {
tableType: "TABLE",
};
const mysqlTableNode: TreeNode = {
...tableNode,
id: "table-zcyy-write-off-record",
label: "zcyy_write_off_record",
database: "yf_db",
schema: undefined,
};
describe("useSidebarDataOpenRuntime", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -121,6 +130,7 @@ describe("useSidebarDataOpenRuntime", () => {
mocks.cachedMetadata = undefined;
mocks.reuseDataTab = false;
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM users");
mocks.executeTabSql.mockImplementation(async () => {
mocks.callOrder.push("query");
});
@ -188,6 +198,55 @@ describe("useSidebarDataOpenRuntime", () => {
});
});
it("keeps MySQL data-tab identity unqualified after metadata loads", async () => {
mocks.databaseType = "mysql";
mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({
metadata: {
schema: request.schema,
tableName: request.tableName,
tableType: request.tableType,
database: request.database,
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
}));
await useSidebarDataOpenRuntime().openData(mysqlTableNode);
await vi.waitFor(() => expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]));
expect(mocks.loadTableMetadata).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: "yf_db" }));
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" }));
expect(mocks.tabs[0]?.tableMeta).toMatchObject({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" });
});
it("keeps cached MySQL table metadata unqualified", async () => {
mocks.databaseType = "mysql";
mocks.cachedMetadata = {
metadata: {
schema: "yf_db",
tableName: "zcyy_write_off_record",
tableType: "TABLE",
database: "yf_db",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "hit",
ageMs: 0,
};
await useSidebarDataOpenRuntime().openData(mysqlTableNode);
expect(mocks.loadTableMetadata).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" }));
expect(mocks.tabs[0]?.tableMeta).toMatchObject({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" });
});
it("keeps row identity pending while delayed metadata is in flight and the query finishes first", async () => {
// 元数据延迟:查询先返回,元数据仍挂起
let releaseMetadata: () => void = () => {};

View File

@ -108,7 +108,7 @@ export function useSidebarDataOpenRuntime() {
});
return;
}
const nextTableMeta = tableMetadataToDataTabMeta(loadedMetadata.metadata, tableSchema);
const nextTableMeta = tableMetadataToDataTabMeta(loadedMetadata.metadata, { schema: tableSchema });
queryStore.setTableMeta(targetTabId, nextTableMeta);
openDataLog("info", "metadata:done", {
traceId,
@ -223,7 +223,7 @@ export function useSidebarDataOpenRuntime() {
// 空列的共享缓存条目不算暖缓存columns=[] 无法区分"表确实无列"与
// 占位/异常态,按暖缓存跳过 pending 与刷新会让整行 WHERE 保存路径
// 在行标识未知时重新可用(#3727 审查意见)
const cachedTableMeta = sharedCachedTableMeta?.metadata.columns.length ? tableMetadataToDataTabMeta(sharedCachedTableMeta.metadata, tableSchema) : tabCachedTableMeta;
const cachedTableMeta = sharedCachedTableMeta?.metadata.columns.length ? tableMetadataToDataTabMeta(sharedCachedTableMeta.metadata, { schema: tableSchema }) : tabCachedTableMeta;
const cachedTableMetaAgeMs = sharedCachedTableMeta?.metadata.columns.length ? sharedCachedTableMeta.ageMs : existingTableMetaAgeMs;
const cachedTableMetaSource = sharedCachedTableMeta?.metadata.columns.length ? "shared" : tabCachedTableMeta ? "tab" : undefined;
queryStore.setTableMeta(

View File

@ -103,9 +103,9 @@ export function getCachedTableMetadata(request: Pick<TableMetadataRequest, "conn
return { metadata: hit.value, cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs };
}
export function tableMetadataToDataTabMeta(metadata: TableMetadata, schema = metadata.schema): NonNullable<QueryTab["tableMeta"]> {
export function tableMetadataToDataTabMeta(metadata: TableMetadata, overrides?: { schema?: string }): NonNullable<QueryTab["tableMeta"]> {
return {
schema,
schema: overrides ? overrides.schema : metadata.schema,
tableName: metadata.tableName,
tableType: metadata.tableType,
catalog: metadata.catalog,

View File

@ -166,6 +166,33 @@ describe("queryStore table data refresh", () => {
expect(store.tabs.find((tab) => tab.id === secondTabId)?.result).toBeUndefined();
});
it("keeps a MySQL table refresh unqualified in the selected database context", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "mysql-1",
name: "MySQL Proxy",
db_type: "mysql",
database: "yf_db",
query_timeout_secs: 30,
});
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM `zcyy_write_off_record` LIMIT 100;");
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "yf_db", "zcyy_write_off_record", "data");
store.setTableMeta(tabId, {
database: "yf_db",
schema: undefined,
tableName: "zcyy_write_off_record",
tableType: "TABLE",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
primaryKeys: ["id"],
});
await expect(store.refreshDataTab(tabId)).resolves.toBe(true);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "mysql", database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" }));
expect(store.tabs.find((tab) => tab.id === tabId)?.sql).toBe("SELECT * FROM `zcyy_write_off_record` LIMIT 100;");
});
it("clears a structured sort when refreshed table metadata no longer contains its column", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();