From ff874e8af98f44b3998a56b14c22a8fbf8d14801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Fri, 18 Sep 2026 13:37:33 +0800 Subject: [PATCH] feat(editor): configurable default sort when opening tables --- .../editor/EditorSettingsDialog.vue | 47 ++++++++++++++++ .../__tests__/useNavigationTargets.spec.ts | 50 +++++++++++++++-- .../useSidebarDataOpenRuntime.spec.ts | 54 +++++++++++++++++- .../src/composables/useNavigationTargets.ts | 46 ++++++++++----- .../composables/useSidebarDataOpenRuntime.ts | 17 +++++- apps/desktop/src/i18n/locales/az.ts | 9 +++ apps/desktop/src/i18n/locales/en.ts | 9 +++ apps/desktop/src/i18n/locales/es.ts | 9 +++ apps/desktop/src/i18n/locales/it.ts | 9 +++ apps/desktop/src/i18n/locales/ja.ts | 9 +++ apps/desktop/src/i18n/locales/ko.ts | 9 +++ apps/desktop/src/i18n/locales/pt-BR.ts | 9 +++ apps/desktop/src/i18n/locales/tr.ts | 9 +++ apps/desktop/src/i18n/locales/zh-CN.ts | 9 +++ apps/desktop/src/i18n/locales/zh-TW.ts | 9 +++ .../__tests__/table/tableDefaultSort.spec.ts | 56 +++++++++++++++++++ .../src/lib/metadata/tableMetadataCache.ts | 3 +- .../__tests__/settingsTransfer.spec.ts | 16 ++++++ .../src/lib/settings/editorSettingsDraft.ts | 3 + .../src/lib/settings/settingsSearch.ts | 3 + .../src/lib/settings/settingsTransfer.ts | 3 + .../desktop/src/lib/table/tableDefaultSort.ts | 31 ++++++++++ apps/desktop/src/lib/table/tableEditing.ts | 7 +++ apps/desktop/src/stores/settingsStore.ts | 12 ++++ apps/desktop/src/types/database.ts | 2 + 25 files changed, 417 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/table/tableDefaultSort.spec.ts create mode 100644 apps/desktop/src/lib/table/tableDefaultSort.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index ecd0bb12fa..f785c89ff3 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -579,6 +579,9 @@ const editDataGridCellDetailButtonVisible = ref(settingsStore.editorSettings.dat const editDataGridCrosshairHighlight = ref(settingsStore.editorSettings.dataGridCrosshairHighlight); const editPageSize = ref(settingsStore.editorSettings.pageSize); const editTableOpenPageSize = ref(settingsStore.editorSettings.tableOpenPageSize); +const editTableOpenSortMode = ref(settingsStore.editorSettings.tableOpenSortMode); +const editTableDatabaseSortDirection = ref(settingsStore.editorSettings.tableDatabaseSortDirection); +const editTableLocalSortDirection = ref(settingsStore.editorSettings.tableLocalSortDirection); const editQueryResultMaxRowsEnabled = ref(settingsStore.editorSettings.queryResultMaxRowsEnabled); const editQueryResultMaxRows = ref(settingsStore.editorSettings.queryResultMaxRows); const editExternalSqlEditorMaxMb = ref(settingsStore.editorSettings.externalSqlEditorMaxMb); @@ -887,6 +890,9 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { dataGridShowWhitespace: editDataGridShowWhitespace.value, pageSize: editPageSize.value, tableOpenPageSize: editTableOpenPageSize.value, + tableOpenSortMode: editTableOpenSortMode.value, + tableDatabaseSortDirection: editTableDatabaseSortDirection.value, + tableLocalSortDirection: editTableLocalSortDirection.value, queryResultMaxRowsEnabled: editQueryResultMaxRowsEnabled.value, queryResultMaxRows: editQueryResultMaxRows.value, externalSqlEditorMaxMb: editExternalSqlEditorMaxMb.value, @@ -1516,6 +1522,9 @@ function syncEditorSettingsDraftFromStore() { editDataGridShowWhitespace.value = settingsStore.editorSettings.dataGridShowWhitespace; editPageSize.value = settingsStore.editorSettings.pageSize; editTableOpenPageSize.value = settingsStore.editorSettings.tableOpenPageSize; + editTableOpenSortMode.value = settingsStore.editorSettings.tableOpenSortMode; + editTableDatabaseSortDirection.value = settingsStore.editorSettings.tableDatabaseSortDirection; + editTableLocalSortDirection.value = settingsStore.editorSettings.tableLocalSortDirection; editQueryResultMaxRowsEnabled.value = settingsStore.editorSettings.queryResultMaxRowsEnabled; editQueryResultMaxRows.value = settingsStore.editorSettings.queryResultMaxRows; editExternalSqlEditorMaxMb.value = settingsStore.editorSettings.externalSqlEditorMaxMb; @@ -1634,6 +1643,9 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = { dataGridCrosshairHighlight: editDataGridCrosshairHighlight, pageSize: editPageSize, tableOpenPageSize: editTableOpenPageSize, + tableOpenSortMode: editTableOpenSortMode, + tableDatabaseSortDirection: editTableDatabaseSortDirection, + tableLocalSortDirection: editTableLocalSortDirection, queryResultMaxRowsEnabled: editQueryResultMaxRowsEnabled, queryResultMaxRows: editQueryResultMaxRows, externalSqlEditorMaxMb: editExternalSqlEditorMaxMb, @@ -2118,6 +2130,9 @@ function resetDefaultsForTab(tab: SettingsCategory) { editDataGridShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.dataGridShowWhitespace; editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize; editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize; + editTableOpenSortMode.value = DEFAULT_EDITOR_SETTINGS.tableOpenSortMode; + editTableDatabaseSortDirection.value = DEFAULT_EDITOR_SETTINGS.tableDatabaseSortDirection; + editTableLocalSortDirection.value = DEFAULT_EDITOR_SETTINGS.tableLocalSortDirection; editQueryResultMaxRowsEnabled.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRowsEnabled; editQueryResultMaxRows.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRows; editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll; @@ -2212,6 +2227,9 @@ function resetAllDefaults() { editDataGridShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.dataGridShowWhitespace; editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize; editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize; + editTableOpenSortMode.value = DEFAULT_EDITOR_SETTINGS.tableOpenSortMode; + editTableDatabaseSortDirection.value = DEFAULT_EDITOR_SETTINGS.tableDatabaseSortDirection; + editTableLocalSortDirection.value = DEFAULT_EDITOR_SETTINGS.tableLocalSortDirection; editQueryResultMaxRowsEnabled.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRowsEnabled; editQueryResultMaxRows.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRows; editExternalSqlEditorMaxMb.value = DEFAULT_EDITOR_SETTINGS.externalSqlEditorMaxMb; @@ -7335,6 +7353,35 @@ onUnmounted(() => { @update:model-value="updatePageSizeDraft" /> +
+
+ +

{{ t("settings.tableOpenSortDescription") }}

+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index 156ce8884c..da3af7e023 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -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()), 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"; diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index d14be074be..20fbb44581 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -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()), 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"; diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index e3823f2e8a..1e0befeb26 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -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> | 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); diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index b47362197a..54c979831a 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -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); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 1904793a77..bdab88b1e6 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -6704,6 +6704,15 @@ export default withEnglishFallback({ syncSuccess: "Verilənlərin sinxronlaşdırılması uğurla icra edildi", }, settings: { + tableOpenSortMode: "Cədvəli açarkən sıralama", + tableDatabaseSortDirection: "Verilənlər bazası sıralama istiqaməti", + tableLocalSortDirection: "Cari səhifənin sıralama istiqaməti", + tableOpenSortDescription: "Yeni cədvəllər: bazada bütün əsas açarlar, yerli sıralamada ilk əsas açar istifadə olunur. Dəstəklənmədikdə mövcud sıra saxlanılır.", + tableSortUnchanged: "Mövcud davranışı saxla", + tableSortDatabase: "Verilənlər bazasında sırala", + tableSortLocal: "Cari səhifəni sırala", + tableSortAscending: "Artan ASC", + tableSortDescending: "Azalan DESC", title: "Parametrlər", searchSettings: "Parametrləri axtar", searchSettingsNoResults: "Uyğun parametr yoxdur", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5791ad3c83..ce899ab698 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6930,6 +6930,15 @@ export default { syncSuccess: "Data sync executed successfully", }, settings: { + tableOpenSortMode: "Default table sort", + tableDatabaseSortDirection: "Default database sort direction", + tableLocalSortDirection: "Default current page sort direction", + tableOpenSortDescription: "For newly opened tables, database sorting uses the complete primary key; current page sorting uses its first column and only loaded rows. Keep existing behavior when unavailable or unsupported.", + tableSortUnchanged: "Keep existing behavior", + tableSortDatabase: "Database sorting", + tableSortLocal: "Current page sorting", + tableSortAscending: "Ascending ASC", + tableSortDescending: "Descending DESC", title: "Settings", searchSettings: "Search settings", searchSettingsNoResults: "No matching settings", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 1a951e9b07..3bee2fd85b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6491,6 +6491,15 @@ export default withEnglishFallback({ syncSuccess: "Sincronización de datos ejecutada exitosamente", }, settings: { + tableOpenSortMode: "Orden predeterminado de tablas", + tableDatabaseSortDirection: "Dirección del orden en la base de datos", + tableLocalSortDirection: "Dirección del orden de la página actual", + tableOpenSortDescription: "En tablas nuevas, el orden en la base de datos usa toda la clave primaria; el local usa su primera columna y solo filas cargadas. Si no está disponible, se conserva el orden existente.", + tableSortUnchanged: "Mantener comportamiento actual", + tableSortDatabase: "Ordenar en la base de datos", + tableSortLocal: "Ordenar página actual", + tableSortAscending: "Ascendente ASC", + tableSortDescending: "Descendente DESC", title: "Configuración", searchSettings: "Buscar configuración", searchSettingsNoResults: "No hay ajustes coincidentes", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index f7e16d9ec3..86968d3e23 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6491,6 +6491,15 @@ export default withEnglishFallback({ syncSuccess: "Sincronizzazione dei dati eseguita con successo", }, settings: { + tableOpenSortMode: "Ordinamento predefinito delle tabelle", + tableDatabaseSortDirection: "Direzione nel database", + tableLocalSortDirection: "Direzione nella pagina corrente", + tableOpenSortDescription: "Per le nuove tabelle, il database usa tutta la chiave primaria; l’ordinamento locale usa la prima colonna e solo le righe caricate. Se non disponibile, mantiene l’ordine esistente.", + tableSortUnchanged: "Mantieni comportamento attuale", + tableSortDatabase: "Ordina nel database", + tableSortLocal: "Ordina pagina corrente", + tableSortAscending: "Crescente ASC", + tableSortDescending: "Decrescente DESC", title: "Impostazioni", searchSettings: "Cerca impostazioni", searchSettingsNoResults: "Nessuna impostazione corrispondente", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e9068e7703..2f33d0a22c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6529,6 +6529,15 @@ export default withEnglishFallback({ syncSuccess: "データ同期が正常に実行されました", }, settings: { + tableOpenSortMode: "テーブルの既定の並び順", + tableDatabaseSortDirection: "データベースの既定のソート方向", + tableLocalSortDirection: "現在のページの既定のソート方向", + tableOpenSortDescription: "新しく開くテーブルに適用します。データベースでは主キー全体、ページ内では最初の主キー列で読み込み済みの行を並べ替えます。利用できない場合は従来の順序を維持します。", + tableSortUnchanged: "現在の動作を維持", + tableSortDatabase: "データベース全体でソート", + tableSortLocal: "現在のページでソート", + tableSortAscending: "昇順 ASC", + tableSortDescending: "降順 DESC", title: "設定", searchSettings: "設定を検索", searchSettingsNoResults: "一致する設定がありません", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9c0c0f33f1..82cbae7e30 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6241,6 +6241,15 @@ export default withEnglishFallback({ syncSuccess: "데이터 동기화를 성공적으로 실행했습니다", }, settings: { + tableOpenSortMode: "테이블 기본 정렬", + tableDatabaseSortDirection: "데이터베이스 기본 정렬 방향", + tableLocalSortDirection: "현재 페이지 기본 정렬 방향", + tableOpenSortDescription: "새 테이블에 적용합니다. 데이터베이스 정렬은 전체 기본 키를, 로컬 정렬은 첫 번째 기본 키 열과 로드된 행만 사용합니다. 지원하지 않으면 기존 순서를 유지합니다.", + tableSortUnchanged: "기존 동작 유지", + tableSortDatabase: "데이터베이스 정렬", + tableSortLocal: "현재 페이지 정렬", + tableSortAscending: "오름차순 ASC", + tableSortDescending: "내림차순 DESC", title: "설정", searchSettings: "설정 검색", searchSettingsNoResults: "일치하는 설정이 없습니다", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e354a6f5bd..5e19a56945 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6493,6 +6493,15 @@ export default withEnglishFallback({ syncSuccess: "Sincronização de dados executada com sucesso", }, settings: { + tableOpenSortMode: "Ordenação padrão das tabelas", + tableDatabaseSortDirection: "Direção padrão no banco de dados", + tableLocalSortDirection: "Direção padrão na página atual", + tableOpenSortDescription: "Em novas tabelas, o banco usa toda a chave primária; a ordenação local usa a primeira coluna e apenas linhas carregadas. Quando indisponível, mantém a ordem existente.", + tableSortUnchanged: "Manter comportamento atual", + tableSortDatabase: "Ordenar no banco de dados", + tableSortLocal: "Ordenar página atual", + tableSortAscending: "Crescente ASC", + tableSortDescending: "Decrescente DESC", title: "Configurações", searchSettings: "Pesquisar configurações", searchSettingsNoResults: "Nenhuma configuração encontrada", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 94065eef39..0a8c9e6fff 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -6586,6 +6586,15 @@ export default withEnglishFallback({ syncSuccess: "Veri eşitlemesi başarıyla çalıştırıldı", }, settings: { + tableOpenSortMode: "Varsayılan tablo sıralaması", + tableDatabaseSortDirection: "Varsayılan veritabanı sıralama yönü", + tableLocalSortDirection: "Geçerli sayfanın varsayılan sıralama yönü", + tableOpenSortDescription: "Yeni tablolarda veritabanı sıralaması tüm birincil anahtarı, yerel sıralama ilk anahtar sütununu ve yüklenen satırları kullanır. Desteklenmiyorsa mevcut sıra korunur.", + tableSortUnchanged: "Mevcut davranışı koru", + tableSortDatabase: "Veritabanında sırala", + tableSortLocal: "Geçerli sayfayı sırala", + tableSortAscending: "Artan ASC", + tableSortDescending: "Azalan DESC", title: "Ayarlar", searchSettings: "Ayarlarda ara", searchSettingsNoResults: "Eşleşen ayar yok", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index c622ecea66..de7ca4088e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6904,6 +6904,15 @@ export default withEnglishFallback({ syncSuccess: "数据同步执行成功", }, settings: { + tableOpenSortMode: "打开表默认排序", + tableDatabaseSortDirection: "全表默认排序方向", + tableLocalSortDirection: "当前页默认排序方向", + tableOpenSortDescription: "仅影响新打开的表。全表按完整主键排序,当前页按第一个主键字段排序且仅处理已加载数据;无主键或不支持时保持原行为。", + tableSortUnchanged: "保持现有行为", + tableSortDatabase: "全表排序", + tableSortLocal: "当前页排序", + tableSortAscending: "升序 ASC", + tableSortDescending: "降序 DESC", title: "设置", searchSettings: "搜索设置", searchSettingsNoResults: "未找到匹配的设置项", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2c41bf283f..58ded5af6e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5823,6 +5823,15 @@ export default withEnglishFallback({ syncSuccess: "資料同步執行成功", }, settings: { + tableOpenSortMode: "開啟表格預設排序", + tableDatabaseSortDirection: "全表預設排序方向", + tableLocalSortDirection: "目前頁面預設排序方向", + tableOpenSortDescription: "僅影響新開啟的表格。全表按完整主鍵排序,目前頁面按第一個主鍵欄位排序且僅處理已載入資料;無主鍵或不支援時維持原行為。", + tableSortUnchanged: "維持現有行為", + tableSortDatabase: "全表排序", + tableSortLocal: "目前頁面排序", + tableSortAscending: "升冪 ASC", + tableSortDescending: "降冪 DESC", title: "設定", searchSettings: "搜尋設定", searchSettingsNoResults: "找不到相符的設定項目", diff --git a/apps/desktop/src/lib/__tests__/table/tableDefaultSort.spec.ts b/apps/desktop/src/lib/__tests__/table/tableDefaultSort.spec.ts new file mode 100644 index 0000000000..6974d98d41 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/tableDefaultSort.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyTableDefaultSortResult, resolveTableDefaultSort } from "@/lib/table/tableDefaultSort"; +import { editableRowIdentifierColumns, physicalTablePrimaryKeys } from "@/lib/table/tableEditing"; +import type { ColumnInfo, QueryTab } from "@/types/database"; + +const settings = { tableOpenSortMode: "database", tableDatabaseSortDirection: "desc", tableLocalSortDirection: "asc" } as const; +const column: ColumnInfo = { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }; + +describe("table default sorting", () => { + it.each(["oracle", "oceanbase-oracle", "xugu", "neo4j"] as const)("preserves existing ordering for %s synthetic row identities in both modes", (databaseType) => { + const keys = editableRowIdentifierColumns(databaseType, [column], [], "TABLE"); + expect(keys).toHaveLength(1); + for (const tableOpenSortMode of ["database", "local"] as const) { + const sort = resolveTableDefaultSort({ ...settings, tableOpenSortMode }, databaseType, physicalTablePrimaryKeys([column])); + const tab = { id: "tab-1", result: { columns: keys, rows: [["2"], ["1"]] } } as QueryTab; + const sortLocally = vi.fn(); + applyTableDefaultSortResult(tab, sort, sortLocally); + expect(sort.orderBy).toBeUndefined(); + expect(sortLocally).not.toHaveBeenCalled(); + expect(tab.orderByInput).toBeUndefined(); + expect(tab.resultSortMode).toBeUndefined(); + expect(tab.result?.rows).toEqual([["2"], ["1"]]); + } + }); + + it.each(["oracle", "neo4j"] as const)("still sorts real %s primary keys", (databaseType) => { + const columns = [{ ...column, is_primary_key: true }]; + const sort = resolveTableDefaultSort(settings, databaseType, physicalTablePrimaryKeys(columns)); + expect(sort.orderBy).toBe(databaseType === "oracle" ? '"id" DESC' : "n.`id` DESC"); + }); + + it.each(["oracle", "oceanbase-oracle"] as const)("sorts %s primary indexes when column metadata omits the key flag", (databaseType) => { + const columns = [{ ...column, name: "id" }]; + const sort = resolveTableDefaultSort(settings, databaseType, physicalTablePrimaryKeys(columns, [{ columns: ["id"], is_primary: true }])); + expect(sort.orderBy).toBe('"id" DESC'); + }); + + it("does not sort by a unique row identifier when the table lacks a physical primary key", () => { + const columns = [{ ...column, name: "email" }]; + const rowIdentifiers = editableRowIdentifierColumns("postgres", columns, [{ name: "users_email_key", columns: ["email"], is_unique: true, is_primary: false, filter: null }]); + expect(rowIdentifiers).toEqual(["email"]); + + const sort = resolveTableDefaultSort(settings, "postgres", physicalTablePrimaryKeys(columns)); + expect(sort.orderBy).toBeUndefined(); + expect(sort.columns).toEqual([]); + }); + + it("preserves composite ordering and escapes identifiers", () => { + const sort = resolveTableDefaultSort(settings, "postgres", ['tenant"id', "id"]); + expect(sort.orderBy).toBe('"tenant""id" DESC, "id" DESC'); + const tab = { id: "tab-1", result: { columns: ['tenant"id', "id"], rows: [] } } as unknown as QueryTab; + applyTableDefaultSortResult(tab, sort, vi.fn()); + expect(tab.orderByInput).toBe(sort.orderBy); + expect(tab.resultSortColumn).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/metadata/tableMetadataCache.ts b/apps/desktop/src/lib/metadata/tableMetadataCache.ts index 537971edd1..c0a5fdaff0 100644 --- a/apps/desktop/src/lib/metadata/tableMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/tableMetadataCache.ts @@ -1,6 +1,6 @@ import type { ColumnInfo, DatabaseType, IndexInfo, QueryTab } from "@/types/database"; import * as api from "@/lib/backend/api"; -import { editableRowIdentifierColumns } from "@/lib/table/tableEditing"; +import { editableRowIdentifierColumns, physicalTablePrimaryKeys } from "@/lib/table/tableEditing"; import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadCacheStatus, type MetadataLoadTraceLogger } from "./metadataLoadCoordinator"; import { metadataScopeKey, metadataScopeParts, type MetadataScopeInput } from "./metadataLoadScope"; import { metadataCacheInvalidationMatcher, MetadataResultCache, type MetadataCacheInvalidation } from "./metadataResultCache"; @@ -275,6 +275,7 @@ export function tableMetadataToDataTabMeta(metadata: TableMetadata, overrides?: database: metadata.database, columns: metadata.columns, primaryKeys: metadata.primaryKeys, + physicalPrimaryKeys: physicalTablePrimaryKeys(metadata.columns, metadata.indexes), }; } diff --git a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts index e063e8f0f7..db874c06af 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts @@ -234,6 +234,22 @@ describe("settingsTransfer", () => { expect(result.value.editorSettings.snippets).toEqual(settings.snippets); }); + it("round-trips table default sorting settings", () => { + const settings = { + ...DEFAULT_EDITOR_SETTINGS, + tableOpenSortMode: "database", + tableDatabaseSortDirection: "desc", + tableLocalSortDirection: "asc", + } as EditorSettings; + const result = parseSettingsTransferFile(serializeSettingsTransfer(settings)); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.editorSettings.tableOpenSortMode).toBe("database"); + expect(result.value.editorSettings.tableDatabaseSortDirection).toBe("desc"); + expect(result.value.editorSettings.tableLocalSortDirection).toBe("asc"); + expect(transferCategoryForKey("tableOpenSortMode")).toBe("data"); + }); + it("rejects custom theme items that lack id or name", () => { const validTheme = { ...DEFAULT_EDITOR_SETTINGS.customThemes[0] }; const idless = parseSettingsTransferFile(fileWith({ customThemes: [{ ...validTheme, id: "" }] })); diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 695f1fd189..0e921edc40 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -60,6 +60,9 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "dataGridCrosshairHighlight", "pageSize", "tableOpenPageSize", + "tableOpenSortMode", + "tableDatabaseSortDirection", + "tableLocalSortDirection", "queryResultMaxRowsEnabled", "queryResultMaxRows", "externalSqlEditorMaxMb", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index 74cfb7d365..70472e294b 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -195,6 +195,9 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "navigation-disconnect-tabs", category: "navigation", titleKey: "settings.disconnectTabHandlingMode", descriptionKey: "settings.disconnectTabHandlingModeDescription", targetId: "navigation" }, { id: "query-page-size", category: "data", titleKey: "settings.queryPageSize", descriptionKey: "settings.queryPageSizeDescription", targetId: "data" }, { id: "data-page-size", category: "data", titleKey: "settings.tableOpenPageSize", descriptionKey: "settings.tableOpenPageSizeDescription", targetId: "data" }, + { id: "data-table-open-sort-mode", category: "data", titleKey: "settings.tableOpenSortMode", descriptionKey: "settings.tableOpenSortDescription", targetId: "data" }, + { id: "data-table-database-sort-direction", category: "data", titleKey: "settings.tableDatabaseSortDirection", targetId: "data" }, + { id: "data-table-local-sort-direction", category: "data", titleKey: "settings.tableLocalSortDirection", targetId: "data" }, { id: "default-auto-keep-results", category: "data", titleKey: "settings.defaultAutoKeepResults", descriptionKey: "settings.defaultAutoKeepResultsDescription", targetId: "default-auto-keep-results" }, { id: "multi-statement-default-view", category: "data", titleKey: "settings.multiStatementDefaultView", descriptionKey: "settings.multiStatementDefaultViewDescription", targetId: "multi-statement-default-view" }, { id: "query-result-max-rows", category: "data", titleKey: "settings.queryResultMaxRows", descriptionKey: "settings.queryResultMaxRowsDescription", targetId: "data" }, diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index d9a4009d3c..7d831124e2 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -153,6 +153,9 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record, databaseType: DatabaseType | undefined, physicalPrimaryKeys: string[], identifierQuote?: string) { + const mode = settings.tableOpenSortMode ?? "none"; + const direction = (mode === "local" ? settings.tableLocalSortDirection : settings.tableDatabaseSortDirection) ?? "asc"; + const columns = databaseType === "influxdb" || databaseType === "influxdb3" ? [] : physicalPrimaryKeys; + const orderBy = mode === "database" && columns.length && databaseSortSupportedForDatabase(databaseType) ? columns.map((column) => `${databaseType === "neo4j" ? "n." : ""}${quoteTableDataIdentifier(databaseType, column, identifierQuote)} ${direction.toUpperCase()}`).join(", ") : undefined; + return { mode, direction, columns, orderBy }; +} + +export function applyTableDefaultSortResult(tab: QueryTab, sort: ReturnType, sortLocally: (id: string, column: string, index: number, direction: "asc" | "desc") => void) { + if (!tab.result || tab.result.execution_error || tab.isExecuting) return; + const column = sort.columns[0]; + const index = column ? tab.result.columns.indexOf(column) : -1; + if (!column || index < 0) return; + if (sort.orderBy) { + tab.orderByInput = sort.orderBy; + // A single-column grid indicator must not overwrite a composite ORDER BY. + if (sort.columns.length === 1) { + tab.resultSortColumn = column; + tab.resultSortColumnIndex = index; + tab.resultSortDirection = sort.direction; + tab.resultSortMode = "database"; + } + } else if (sort.mode === "local" && !tab.result.large_value_cells?.some((cell) => cell.column_index === index)) { + sortLocally(tab.id, column, index, sort.direction); + } +} diff --git a/apps/desktop/src/lib/table/tableEditing.ts b/apps/desktop/src/lib/table/tableEditing.ts index 06d38a916d..2b27eb6b7d 100644 --- a/apps/desktop/src/lib/table/tableEditing.ts +++ b/apps/desktop/src/lib/table/tableEditing.ts @@ -29,6 +29,13 @@ export function editablePrimaryKeys(databaseType: DatabaseType | undefined, colu return primaryKeys; } +/** Physical primary keys only; unlike editable row identifiers, never fall back to unique indexes or synthetic keys. */ +export function physicalTablePrimaryKeys(columns: readonly Pick[], indexes: readonly Pick[] = []): string[] { + const columnPrimaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name); + if (columnPrimaryKeys.length > 0) return columnPrimaryKeys; + return indexes.find((index) => index.is_primary && index.columns.length > 0)?.columns ?? []; +} + export function editableRowIdentifierColumns(databaseType: DatabaseType | undefined, columns: ColumnInfo[], indexes?: IndexInfo[], tableType?: string): string[] { const primaryKeys = editablePrimaryKeys(databaseType, columns, tableType); const oracleRowIdFallback = getDatabaseCapability(databaseType).syntheticKey === "oracle-rowid" && primaryKeys.length === 1 && primaryKeys[0]?.toUpperCase() === DBX_ROWID_COLUMN; diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index c462d4c771..acc9b23436 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -793,6 +793,9 @@ export interface EditorSettings { appLayout: "separated" | "classic"; pageSize: number; tableOpenPageSize: number; + tableOpenSortMode: "none" | "database" | "local"; + tableDatabaseSortDirection: "asc" | "desc"; + tableLocalSortDirection: "asc" | "desc"; queryResultMaxRowsEnabled: boolean; queryResultMaxRows: number; externalSqlEditorMaxMb: number; @@ -1038,6 +1041,9 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { appLayout: "classic", pageSize: 100, tableOpenPageSize: 100, + tableOpenSortMode: "none", + tableDatabaseSortDirection: "asc", + tableLocalSortDirection: "asc", queryResultMaxRowsEnabled: true, queryResultMaxRows: DEFAULT_QUERY_RESULT_MAX_ROWS, externalSqlEditorMaxMb: 64, @@ -1522,6 +1528,9 @@ export function normalizeEditorSettings(settings: Partial, exist appLayout: settings.appLayout ?? DEFAULT_EDITOR_SETTINGS.appLayout, pageSize: normalizeResultPageSize(settings.pageSize), tableOpenPageSize: normalizeResultPageSize(settings.tableOpenPageSize, DEFAULT_EDITOR_SETTINGS.tableOpenPageSize), + tableOpenSortMode: settings.tableOpenSortMode === "database" || settings.tableOpenSortMode === "local" ? settings.tableOpenSortMode : "none", + tableDatabaseSortDirection: settings.tableDatabaseSortDirection === "desc" ? "desc" : "asc", + tableLocalSortDirection: settings.tableLocalSortDirection === "desc" ? "desc" : "asc", queryResultMaxRowsEnabled: settings.queryResultMaxRowsEnabled !== false, queryResultMaxRows: normalizeQueryResultMaxRows(settings.queryResultMaxRows), externalSqlEditorMaxMb: normalizeExternalSqlEditorMaxMb(settings.externalSqlEditorMaxMb), @@ -2289,6 +2298,9 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.appLayout !== undefined) editorSettings.value.appLayout = partial.appLayout; if (partial.pageSize !== undefined) editorSettings.value.pageSize = normalizeResultPageSize(partial.pageSize); if (partial.tableOpenPageSize !== undefined) editorSettings.value.tableOpenPageSize = normalizeResultPageSize(partial.tableOpenPageSize, DEFAULT_EDITOR_SETTINGS.tableOpenPageSize); + if (partial.tableOpenSortMode !== undefined) editorSettings.value.tableOpenSortMode = partial.tableOpenSortMode === "database" || partial.tableOpenSortMode === "local" ? partial.tableOpenSortMode : "none"; + if (partial.tableDatabaseSortDirection !== undefined) editorSettings.value.tableDatabaseSortDirection = partial.tableDatabaseSortDirection === "desc" ? "desc" : "asc"; + if (partial.tableLocalSortDirection !== undefined) editorSettings.value.tableLocalSortDirection = partial.tableLocalSortDirection === "desc" ? "desc" : "asc"; if (partial.queryResultMaxRowsEnabled !== undefined) editorSettings.value.queryResultMaxRowsEnabled = Boolean(partial.queryResultMaxRowsEnabled); if (partial.queryResultMaxRows !== undefined) editorSettings.value.queryResultMaxRows = normalizeQueryResultMaxRows(partial.queryResultMaxRows, editorSettings.value.queryResultMaxRows); if (partial.externalSqlEditorMaxMb !== undefined) editorSettings.value.externalSqlEditorMaxMb = normalizeExternalSqlEditorMaxMb(partial.externalSqlEditorMaxMb); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 2e799c16f2..7e9240e338 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1789,6 +1789,8 @@ export interface QueryTab { database?: string; columns: ColumnInfo[]; primaryKeys: string[]; + /** Physical primary keys used for table-open default sorting; excludes unique and synthetic row identifiers. */ + physicalPrimaryKeys?: string[]; }; tableMetaUpdatedAt?: number; /** 该 tab 的 tableMeta 是哪个连接元数据代次下写入的:disconnect / 关闭数据库 /