feat(editor): configurable default sort when opening tables

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

No files matched your search

@@ -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();
});
});
@@ -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),
};
}
@@ -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: "" }] }));
@@ -60,6 +60,9 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"dataGridCrosshairHighlight",
"pageSize",
"tableOpenPageSize",
"tableOpenSortMode",
"tableDatabaseSortDirection",
"tableLocalSortDirection",
"queryResultMaxRowsEnabled",
"queryResultMaxRows",
"externalSqlEditorMaxMb",
@@ -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" },
@@ -153,6 +153,9 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
"dataGridShowWhitespace",
"pageSize",
"tableOpenPageSize",
"tableOpenSortMode",
"tableDatabaseSortDirection",
"tableLocalSortDirection",
"queryResultMaxRowsEnabled",
"queryResultMaxRows",
"externalSqlEditorMaxMb",
@@ -0,0 +1,31 @@
import type { DatabaseType, QueryTab } from "@/types/database";
import type { EditorSettings } from "@/stores/settingsStore";
import { databaseSortSupportedForDatabase } from "@/lib/dataGrid/dataGridSort";
import { quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
export function resolveTableDefaultSort(settings: Pick<EditorSettings, "tableOpenSortMode" | "tableDatabaseSortDirection" | "tableLocalSortDirection">, 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<typeof resolveTableDefaultSort>, 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);
}
}
@@ -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<ColumnInfo, "name" | "is_primary_key">[], indexes: readonly Pick<IndexInfo, "columns" | "is_primary">[] = []): 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;