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"
/>
+
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 / 关闭数据库 /