feat(editor): configurable default sort when opening tables

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-18 13:37:33 +08:00
1 parent 5cdee2e7a3
commit ff874e8af9
25 files changed
+417 -23

No files matched your search

@@ -8,11 +8,13 @@ const mocks = vi.hoisted(() => ({
activeTabId: "",
buildTableSelectSql: vi.fn(),
databaseType: "postgres" as string,
tableOpenSortMode: "none" as "none" | "database" | "local",
ensureConnected: vi.fn(),
executeTabSql: vi.fn(),
getColumns: vi.fn(),
invalidateCompletionTableCache: vi.fn(),
listIndexes: vi.fn(),
setErrorResult: vi.fn(),
setTableMeta: vi.fn(),
updateSql: vi.fn(),
}));
@@ -75,16 +77,17 @@ vi.mock("@/stores/queryStore", () => ({
}),
updateSql: mocks.updateSql,
executeTabSql: mocks.executeTabSql,
setErrorResult: vi.fn(),
setErrorResult: mocks.setErrorResult,
invalidateTableStructure: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: "same-table" } }),
useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: "same-table", tableOpenSortMode: mocks.tableOpenSortMode, tableDatabaseSortDirection: "desc", tableLocalSortDirection: "asc" } }),
}));
vi.mock("@/lib/table/tableSelectSql", () => ({
vi.mock("@/lib/table/tableSelectSql", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/table/tableSelectSql")>()),
buildTableSelectSql: mocks.buildTableSelectSql,
}));
@@ -104,6 +107,7 @@ describe("useNavigationTargets openTableTarget", () => {
vi.clearAllMocks();
mocks.tabs.length = 0;
mocks.databaseType = "postgres";
mocks.tableOpenSortMode = "none";
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.buildTableSelectSql.mockImplementation(async ({ tableName }: { tableName: string }) => `SELECT * FROM ${tableName}`);
mocks.executeTabSql.mockResolvedValue(undefined);
@@ -111,6 +115,33 @@ describe("useNavigationTargets openTableTarget", () => {
mocks.listIndexes.mockResolvedValue([]);
});
it.each(["oracle", "oceanbase-oracle", "xugu", "neo4j"])("keeps %s synthetic row identifiers out of default database ordering", async (databaseType) => {
mocks.databaseType = databaseType;
mocks.tableOpenSortMode = "database";
mocks.getColumns.mockResolvedValue([{ ...column("name"), is_primary_key: false }]);
await useNavigationTargets(dialogs).openTableTarget({ connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" });
expect(mocks.executeTabSql).toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalled();
for (const [options] of mocks.buildTableSelectSql.mock.calls) {
expect(options.orderBy).toBeUndefined();
}
expect(mocks.tabs[0]?.orderByInput).toBeUndefined();
});
it.each(["oracle", "oceanbase-oracle"])("uses the %s physical primary index for default ordering", async (databaseType) => {
mocks.databaseType = databaseType;
mocks.tableOpenSortMode = "database";
mocks.getColumns.mockResolvedValue([{ ...column("id"), is_primary_key: false }]);
mocks.listIndexes.mockResolvedValue([{ name: "users_pk", columns: ["id"], is_unique: true, is_primary: true }]);
await useNavigationTargets(dialogs).openTableTarget({ connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" });
expect(mocks.setErrorResult).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"id" DESC' }));
});
it("loads stable row identity before the first PostgreSQL table query", async () => {
let releaseColumns: (columns: unknown[]) => void = () => {};
mocks.getColumns.mockReturnValueOnce(
@@ -162,7 +193,7 @@ describe("useNavigationTargets openTableTarget", () => {
});
it("keeps row identity pending when shared metadata loading fails", async () => {
mocks.getColumns.mockRejectedValueOnce(new Error("metadata unavailable"));
mocks.getColumns.mockRejectedValue(new Error("metadata unavailable"));
await useNavigationTargets(dialogs).openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "users" });
@@ -170,6 +201,17 @@ describe("useNavigationTargets openTableTarget", () => {
expect(mocks.tabs[0]?.tableMetaPending).toBe(true);
});
it.each(["none", "database", "local"] as const)("opens the table when metadata preload fails in %s sort mode", async (mode) => {
mocks.databaseType = "sqlite";
mocks.tableOpenSortMode = mode;
mocks.getColumns.mockRejectedValue(new Error("metadata unavailable"));
await useNavigationTargets(dialogs).openLineageTarget({ connectionId: "connection-1", database: "app", tableName: "users" });
expect(mocks.executeTabSql).toHaveBeenCalledTimes(1);
expect(mocks.tabs[0]?.tableMetaPending).toBe(true);
});
it("uses the SQLite attached database as the table namespace when a target omits schema", async () => {
mocks.databaseType = "sqlite";
@@ -5,6 +5,8 @@ import type { QueryTab, TreeNode } from "@/types/database";
const mocks = vi.hoisted(() => ({
databaseType: "oceanbase" as string,
tableOpenSortMode: "none" as "none" | "database" | "local",
sortTabResultLocally: vi.fn(),
callOrder: [] as string[],
tabs: [] as QueryTab[],
activeTabId: null as string | null,
@@ -90,12 +92,13 @@ vi.mock("@/stores/queryStore", () => ({
if (tab) tab.sql = sql;
},
executeTabSql: mocks.executeTabSql,
sortTabResultLocally: mocks.sortTabResultLocally,
setErrorResult: mocks.setErrorResult,
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: mocks.dataTabReuseMode, openDataTabsNextToActive: mocks.openDataTabsNextToActive, pageSize: 100 } }),
useSettingsStore: () => ({ editorSettings: { tableOpenSortMode: mocks.tableOpenSortMode, tableDatabaseSortDirection: "desc", tableLocalSortDirection: "asc", dataTabReuseMode: mocks.dataTabReuseMode, openDataTabsNextToActive: mocks.openDataTabsNextToActive, pageSize: 100 } }),
}));
vi.mock("@/lib/database/jdbcDialect", () => ({
@@ -117,8 +120,12 @@ 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: mocks.buildTableSelectSql }));
vi.mock("@/lib/table/tableSelectSql", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/lib/table/tableSelectSql")>()), buildTableSelectSql: mocks.buildTableSelectSql }));
vi.mock("@/lib/table/tableEditing", () => ({
physicalTablePrimaryKeys: (columns: Array<{ name: string; is_primary_key: boolean }>, indexes: Array<{ columns: string[]; is_primary: boolean }> = []) => {
const columnPrimaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
return columnPrimaryKeys.length > 0 ? columnPrimaryKeys : (indexes.find((index) => index.is_primary && index.columns.length > 0)?.columns ?? []);
},
usesSyntheticRowIdKey: () => false,
shouldIncludeSyntheticRowId: () => false,
}));
@@ -147,6 +154,7 @@ describe("useSidebarDataOpenRuntime", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.databaseType = "oceanbase";
mocks.tableOpenSortMode = "none";
mocks.callOrder.length = 0;
mocks.tabs.length = 0;
mocks.activeTabId = null;
@@ -178,6 +186,48 @@ describe("useSidebarDataOpenRuntime", () => {
});
});
it.each(["database", "local"] as const)("applies the %s default using its independent direction", async (mode) => {
mocks.tableOpenSortMode = mode;
mocks.executeTabSql.mockImplementation(async () => {
const tab = mocks.tabs[0]!;
tab.isExecuting = false;
tab.executionId = undefined;
tab.result = { columns: ["id"], rows: [[2], [1]], affected_rows: 0, execution_time_ms: 0 } as any;
});
await useSidebarDataOpenRuntime().openData(tableNode);
if (mode === "database") {
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"id" DESC' }));
expect(mocks.tabs[0]).toMatchObject({ resultSortColumn: "id", resultSortDirection: "desc", resultSortMode: "database" });
} else {
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined }));
expect(mocks.sortTabResultLocally).toHaveBeenCalledWith("tab-1", "id", 0, "asc");
}
});
it("applies database sorting from a physical primary index when columns omit the key flag", async () => {
mocks.databaseType = "oracle";
mocks.tableOpenSortMode = "database";
mocks.loadTableMetadata.mockResolvedValue({
metadata: {
schema: "public",
tableName: "users",
tableType: "TABLE",
database: "app",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
indexes: [{ name: "users_pk", columns: ["id"], is_unique: true, is_primary: true }],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
});
await useSidebarDataOpenRuntime().openData(tableNode);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"id" DESC' }));
expect(mocks.tabs[0]?.tableMeta?.physicalPrimaryKeys).toEqual(["id"]);
});
it("creates a new sidebar tab for the same table in always-new mode", async () => {
mocks.dataTabReuseMode = "always-new";
@@ -4,8 +4,9 @@ import { invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/
import { canApplyDataTabMetadata, canReuseActiveMongoTab, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isNoSnapshotErrorResult, isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import { resolveTableDefaultSort, applyTableDefaultSortResult } from "@/lib/table/tableDefaultSort";
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
import { editableRowIdentifierColumns, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
import { editableRowIdentifierColumns, physicalTablePrimaryKeys, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { uuid } from "@/lib/common/utils";
import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration";
@@ -142,6 +143,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
if (config.db_type === "neo4j") {
const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName);
const primaryKeys = editableRowIdentifierColumns(effectiveDbType, columns, undefined, targetTableType);
const defaultSort = resolveTableDefaultSort(settingsStore.editorSettings, effectiveDbType, physicalTablePrimaryKeys(columns), identifierQuote);
const sql = await buildTableSelectSql({
databaseType: effectiveDbType,
driverProfile: config.driver_profile,
@@ -155,6 +157,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
tableType: targetTableType,
columns: columns.map((column) => column.name),
primaryKeys,
orderBy: defaultSort.orderBy,
whereInput: target.whereInput,
limit: pageLimit,
});
@@ -170,24 +173,34 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
primaryKeys,
});
firstExecuteStarted = true;
if (targetTab) targetTab.orderByInput = defaultSort.orderBy;
await queryStore.executeTabSql(tabId, sql, { pagination: { limit: pageLimit, offset: 0 } });
if (isCurrentTarget() && targetTab) applyTableDefaultSortResult(targetTab, defaultSort, queryStore.sortTabResultLocally);
return;
}
const eagerMetadata =
effectiveDbType === "mysql" || effectiveDbType === "postgres"
? await loadTableMetadata({
connectionId: target.connectionId,
database: target.database,
schema: querySchema,
tableName: target.tableName,
tableType: targetTableType,
databaseType: effectiveDbType,
driverProfile: config.driver_profile || config.db_type,
catalog: target.catalog,
})
: undefined;
let eagerMetadata: Awaited<ReturnType<typeof loadTableMetadata>> | undefined;
if (effectiveDbType === "mysql" || effectiveDbType === "postgres" || (settingsStore.editorSettings.tableOpenSortMode ?? "none") !== "none") {
try {
eagerMetadata = await loadTableMetadata({
connectionId: target.connectionId,
database: target.database,
schema: querySchema,
tableName: target.tableName,
tableType: targetTableType,
databaseType: effectiveDbType ?? config.db_type,
driverProfile: config.driver_profile || config.db_type,
catalog: target.catalog,
});
} catch (error) {
// Metadata is needed for optional default sorting, but it must not
// prevent the data preview from opening. The later metadata refresh
// keeps the tab pending for edits and can retry independently.
console.warn("[DBX] unable to preload table metadata for default sort", error);
}
}
const eagerColumns = eagerMetadata?.metadata.columns ?? [];
const eagerPrimaryKeys = eagerMetadata?.metadata.primaryKeys ?? [];
const defaultSort = resolveTableDefaultSort(settingsStore.editorSettings, effectiveDbType, physicalTablePrimaryKeys(eagerColumns, eagerMetadata?.metadata.indexes), identifierQuote);
const sql = await buildTableSelectSql({
databaseType: effectiveDbType,
driverProfile: config.driver_profile,
@@ -200,6 +213,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
tableType: targetTableType,
columns: eagerColumns.map((column) => column.name),
primaryKeys: eagerPrimaryKeys,
orderBy: defaultSort.orderBy,
...tableDataLargeValuePreviewOptions(effectiveDbType, eagerColumns, eagerPrimaryKeys, pageLimit),
whereInput: target.whereInput,
limit: pageLimit,
@@ -219,6 +233,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
// 取消计数快照:isCancelling 是瞬态的(取消失败/查询先完成会被清掉),
// 比对计数才能跨越 executeTabSql 生命周期识别"执行期间用户请求过停止"
const cancelCountBeforeExecute = queryStore.tabs.find((tab) => tab.id === tabId)?.cancelRequestCount ?? 0;
if (targetTab) targetTab.orderByInput = defaultSort.orderBy;
await queryStore.executeTabSql(tabId, sql, { pagination: { limit: pageLimit, offset: 0 } });
if (!isCurrentTarget()) return;
// 首次查询被停止/失败(executeTabSql 以 Error 结果表达,不抛出)时,
@@ -228,6 +243,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
const firstResult = tabAfterFirstExecute?.result;
const cancelRequestedDuringExecute = (tabAfterFirstExecute?.cancelRequestCount ?? 0) > cancelCountBeforeExecute;
const firstQueryFailed = cancelRequestedDuringExecute || tabAfterFirstExecute?.isCancelling === true || (firstResult !== undefined && isQueryExecutionErrorResult(firstResult));
if (!firstQueryFailed && targetTab) applyTableDefaultSortResult(targetTab, defaultSort, queryStore.sortTabResultLocally);
// executeTabSql surfaces query failures as an "Error" result instead of throwing.
// A snapshot-less lake table fails the data preview above but its metadata still
// reads fine — retry with LIMIT 0 so the user sees the table structure (columns +
@@ -298,11 +314,13 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
primaryKeys,
columns: columns.map((column) => column.name),
includeRowId: true,
orderBy: defaultSort.orderBy,
limit: pageLimit,
});
if (!isCurrentTarget()) return;
queryStore.updateSql(tabId, newSql);
await queryStore.executeTabSql(tabId, newSql, { pagination: { limit: pageLimit, offset: 0 } });
if (isCurrentTarget() && targetTab) applyTableDefaultSortResult(targetTab, defaultSort, queryStore.sortTabResultLocally);
}
} catch (reason) {
console.error("[DBX] ERROR fetching table metadata:", reason);
@@ -11,7 +11,8 @@ import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataT
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import { shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
import { resolveTableDefaultSort, applyTableDefaultSortResult } from "@/lib/table/tableDefaultSort";
import { physicalTablePrimaryKeys, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
import { canActivateExistingDataTableTab } from "@/lib/tabs/dataTabActivation";
@@ -314,6 +315,7 @@ export function useSidebarDataOpenRuntime() {
if (!config) throw new Error("Connection config not found");
const limit = tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
const defaultSortMode = settingsStore.editorSettings.tableOpenSortMode ?? "none";
const shouldRefreshTableMeta = !cachedTableMeta;
// Dameng metadata calls must remain serialized behind the table query.
const deferTableMetaRefresh = effectiveDbType === "dameng";
@@ -334,7 +336,9 @@ export function useSidebarDataOpenRuntime() {
}
const metadataRefresh = shouldRefreshTableMeta && !deferTableMetaRefresh ? refreshTableMetaInBackground(tabId) : undefined;
if (!cachedTableMeta && (effectiveDbType === "mysql" || effectiveDbType === "postgres")) {
if (!cachedTableMeta && (effectiveDbType === "mysql" || effectiveDbType === "postgres" || defaultSortMode !== "none")) {
// No query is in flight yet, so deferred drivers can safely load metadata serially.
if (deferTableMetaRefresh) await refreshTableMetaInBackground(tabId);
await metadataRefresh;
}
@@ -347,6 +351,8 @@ export function useSidebarDataOpenRuntime() {
const loadedTableMeta = cachedTableMeta ?? queryStore.tabs.find((item) => item.id === tabId)?.tableMeta;
const columns = loadedTableMeta?.columns ?? [];
const primaryKeys = loadedTableMeta?.primaryKeys ?? [];
const defaultSort = resolveTableDefaultSort(settingsStore.editorSettings, effectiveDbType, loadedTableMeta?.physicalPrimaryKeys ?? physicalTablePrimaryKeys(columns), connectionStore.connectionIdentifierQuote?.(node.connectionId));
const defaultOrderBy = defaultSort.orderBy;
const includeRowId = shouldIncludeSyntheticRowId(effectiveDbType, primaryKeys, tableType);
const sql = await buildTableSelectSql({
databaseType: effectiveDbType,
@@ -364,6 +370,7 @@ export function useSidebarDataOpenRuntime() {
limit,
includeRowId,
injectDefaultTimeSeriesWhere: true,
orderBy: defaultOrderBy,
});
// SQL 构建是异步后端调用:期间更晚的导航(openData/openTableTarget)
// 接管会替换 executionId,旧流程不得再覆盖 SQL/启动查询
@@ -380,6 +387,8 @@ export function useSidebarDataOpenRuntime() {
});
logPhase("sql-built", { tabId, columnCount: columns.length, primaryKeyCount: primaryKeys.length });
queryStore.updateSql(tabId, sql);
const openedTab = queryStore.tabs.find((item) => item.id === tabId);
if (openedTab && defaultOrderBy) openedTab.orderByInput = defaultOrderBy;
logPhase("sql-updated", { tabId });
openDataLog("info", "execute:start", { traceId, tabId, elapsed: elapsed() });
@@ -389,6 +398,10 @@ export function useSidebarDataOpenRuntime() {
pagination: { limit, offset: 0 },
});
openDataLog("info", "execute:done", { traceId, tabId, elapsed: elapsed() });
const sortedTab = queryStore.tabs.find((item) => item.id === tabId);
if ((request?.isCurrent() ?? true) && sortedTab && sortedTab === openedTab && sortedTab.sql === sql) {
applyTableDefaultSortResult(sortedTab, defaultSort, queryStore.sortTabResultLocally);
}
logPhase("execute-tab-sql", { tabId });
if (shouldRefreshTableMeta && deferTableMetaRefresh && canApplyTableMetadata(tabId)) {
void refreshTableMetaInBackground(tabId);