feat(data-grid): add configurable default sorting

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-29 04:46:46 +08:00
1 parent fdaef62518
commit 2e5b439c8a
24 files changed
+720 -17

No files matched your search

@@ -14,7 +14,9 @@ const mocks = vi.hoisted(() => ({
getColumns: vi.fn(),
listIndexes: vi.fn(),
ensureConnected: vi.fn(),
sortTabResultLocally: vi.fn(),
tableOpenPageSize: 100,
openTableDefaultSortMode: "none" as "none" | "primary-key-asc" | "primary-key-desc",
tabs: [] as QueryTab[],
setTableMeta: vi.fn(),
}));
@@ -38,6 +40,7 @@ vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
connectionIdentifierQuote: () => undefined,
}),
}));
@@ -46,6 +49,7 @@ vi.mock("@/stores/queryStore", () => ({
executeTabSql: mocks.executeTabSql,
setExecuting: mocks.setExecuting,
updateSql: mocks.updateSql,
sortTabResultLocally: mocks.sortTabResultLocally,
tabs: mocks.tabs,
setTableMeta: mocks.setTableMeta.mockImplementation((id: string, meta: NonNullable<QueryTab["tableMeta"]>) => {
const tab = mocks.tabs.find((item) => item.id === id);
@@ -60,7 +64,14 @@ vi.mock("@/stores/queryStore", () => ({
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { tableOpenPageSize: mocks.tableOpenPageSize } }),
useSettingsStore: () => ({
editorSettings: {
tableOpenPageSize: mocks.tableOpenPageSize,
openTableDefaultSortMode: mocks.openTableDefaultSortMode,
infiniteScroll: false,
infiniteScrollMaxRows: 5000,
},
}),
}));
vi.mock("@/composables/useToast", () => ({
@@ -98,6 +109,7 @@ describe("useDataGridActions", () => {
vi.clearAllMocks();
mocks.tabs.length = 0;
mocks.tableOpenPageSize = 100;
mocks.openTableDefaultSortMode = "none";
mocks.getConfig.mockReturnValue({ id: "postgres-1", db_type: "postgres" });
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM public.users LIMIT 100 OFFSET 0");
mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" });
@@ -305,4 +317,64 @@ describe("useDataGridActions", () => {
}),
);
});
it("rebuilds composite primary-key ordering for toolbar refresh and pagination", async () => {
mocks.openTableDefaultSortMode = "primary-key-desc";
const tab = tableDataTab({
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [
{ name: "tenant_id", data_type: "text", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "record_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
primaryKeys: ["tenant_id", "record_id"],
},
openTableDefaultSortApplied: true,
openTableDefaultSortOrderBy: '"tenant_id" ASC, "record_id" ASC',
orderByInput: '"tenant_id" ASC, "record_id" ASC',
});
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", tab.orderByInput, 25, 50, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenLastCalledWith(
expect.objectContaining({
orderBy: '"tenant_id" DESC, "record_id" DESC',
limit: 25,
offset: 50,
}),
);
await actions.onPaginate(75, 25, "", tab.orderByInput);
expect(mocks.buildTableSelectSql).toHaveBeenLastCalledWith(
expect.objectContaining({
orderBy: '"tenant_id" DESC, "record_id" DESC',
limit: 25,
offset: 75,
}),
);
});
it("sorts locally without issuing a database query", async () => {
const tab = tableDataTab();
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("id", 0, "asc", "", "local");
expect(mocks.sortTabResultLocally).toHaveBeenCalledWith("tab-1", "id", 0, "asc");
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
});
it("sorts in the database by rebuilding and executing table SQL", async () => {
const tab = tableDataTab();
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("id", 0, "desc", "", "database");
expect(mocks.sortTabResultLocally).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"id" DESC' }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", expect.any(String), expect.objectContaining({ preserveResultDuringExecution: true }));
});
});
@@ -9,9 +9,11 @@ const mocks = vi.hoisted(() => ({
cachedMetadata: undefined as unknown,
ensureConnected: vi.fn(),
executeTabSql: vi.fn(),
buildTableSelectSql: vi.fn(),
loadTableMetadata: vi.fn(),
setErrorResult: vi.fn(),
cancelTabExecution: vi.fn(),
openTableDefaultSortMode: "none" as "none" | "primary-key-asc" | "primary-key-desc",
}));
vi.mock("@/stores/connectionStore", () => ({
@@ -69,7 +71,7 @@ vi.mock("@/stores/queryStore", () => ({
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { reuseDataTab: false, pageSize: 100 } }),
useSettingsStore: () => ({ editorSettings: { reuseDataTab: false, tableOpenPageSize: 100, openTableDefaultSortMode: mocks.openTableDefaultSortMode } }),
}));
vi.mock("@/lib/database/jdbcDialect", () => ({
@@ -92,7 +94,10 @@ vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn(), isDebugLoggi
// dataTabOpenPolicy 使用真实实现:beforeEach 清空 tabs 时无候选可复用,
// 取消窗口测试则依赖真实 findExistingDataTabCandidate 选中同表 tab
vi.mock("@/lib/sidebar/treeNodeContext", () => ({ hasTreeNodeDatabaseContext: () => true }));
vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: async () => "SELECT * FROM users" }));
vi.mock("@/lib/table/tableSelectSql", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/table/tableSelectSql")>()),
buildTableSelectSql: mocks.buildTableSelectSql,
}));
vi.mock("@/lib/table/tableEditing", () => ({ usesSyntheticRowIdKey: () => false }));
vi.mock("@/lib/table/tableOpenPageLimit", () => ({ tableOpenPageLimit: () => 100 }));
vi.mock("@/lib/tabs/dataTabActivation", () => ({ canActivateExistingDataTableTab: () => false }));
@@ -114,7 +119,9 @@ describe("useSidebarDataOpenRuntime", () => {
mocks.callOrder.length = 0;
mocks.tabs.length = 0;
mocks.cachedMetadata = undefined;
mocks.openTableDefaultSortMode = "none";
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM users");
mocks.executeTabSql.mockImplementation(async () => {
mocks.callOrder.push("query");
});
@@ -307,4 +314,43 @@ describe("useSidebarDataOpenRuntime", () => {
expect(mocks.tabs[0]?.tableMetaPending).toBeFalsy();
expect(mocks.loadTableMetadata).not.toHaveBeenCalled();
});
it("loads composite primary-key metadata before the first query when default ordering is enabled", async () => {
mocks.databaseType = "postgres";
mocks.openTableDefaultSortMode = "primary-key-asc";
mocks.loadTableMetadata.mockResolvedValueOnce({
metadata: {
schema: "public",
tableName: "users",
tableType: "TABLE",
database: "app",
columns: [
{ name: "Tenant", data_type: "text", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "Order", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
indexes: [],
primaryKeys: ["Tenant", "Order"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
});
await useSidebarDataOpenRuntime().openData(tableNode);
expect(mocks.loadTableMetadata).toHaveBeenCalledTimes(1);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(
expect.objectContaining({
primaryKeys: ["Tenant", "Order"],
orderBy: '"Tenant" ASC, "Order" ASC',
}),
);
expect(mocks.tabs[0]).toMatchObject({
resultSortColumn: "Tenant",
resultSortDirection: "asc",
resultSortMode: "database",
orderByInput: '"Tenant" ASC, "Order" ASC',
openTableDefaultSortApplied: true,
});
});
});
@@ -4,6 +4,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
import { applyOpenTableDefaultSortState, buildOpenTableDefaultSort, clearOpenTableDefaultSortMarker, shouldApplyOpenTableDefaultSort } from "@/lib/table/tableDefaultSort";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
@@ -71,6 +72,31 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
});
}
function openTableDefaultSortForTab(tab: QueryTab) {
const config = connectionStore.getConfig(tab.connectionId);
const tableMeta = tableMetaForDataTab(tab);
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : (tableMeta?.primaryKeys ?? []);
return buildOpenTableDefaultSort({
mode: settingsStore.editorSettings.openTableDefaultSortMode,
databaseType: effectiveDatabaseTypeForConnection(config),
identifierQuote: connectionStore.connectionIdentifierQuote?.(tab.connectionId),
primaryKeys,
columns: tableMeta?.columns.map((column) => column.name) ?? [],
});
}
function resolveTableOrderBy(tab: QueryTab, orderBy?: string): string | undefined {
if (shouldApplyOpenTableDefaultSort(tab, orderBy)) {
const defaultSort = openTableDefaultSortForTab(tab);
applyOpenTableDefaultSortState(tab, defaultSort);
return defaultSort?.orderBy;
}
if (orderBy?.trim() !== tab.openTableDefaultSortOrderBy?.trim()) {
clearOpenTableDefaultSortMarker(tab);
}
return orderBy;
}
async function refreshDataTabTableMeta(tab: QueryTab, trace?: { traceId: string; elapsed: () => string }): Promise<void> {
if (tab.mode !== "data" || !tab.connectionId || !tab.database) return;
const tableMeta = tableMetaForDataTab(tab);
@@ -179,7 +205,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
}
try {
console.info("[DBX][reloadData:build-sql:start]", { traceId, elapsed: elapsed() });
const nextSql = await buildTableSql(tab, { whereInput, orderBy, limit: pageLimit, offset: pageOffset });
const nextOrderBy = resolveTableOrderBy(tab, orderBy);
const nextSql = await buildTableSql(tab, { whereInput, orderBy: nextOrderBy, limit: pageLimit, offset: pageOffset });
console.info("[DBX][reloadData:build-sql:done]", { traceId, elapsed: elapsed() });
queryStore.updateSql(tab.id, nextSql);
console.info("[DBX][reloadData:execute:start]", { traceId, elapsed: elapsed() });
@@ -282,7 +309,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
if (!tableMetaForDataTab(tab)) return;
tab.whereInput = whereInput ?? "";
const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy });
const nextOrderBy = resolveTableOrderBy(tab, orderBy);
const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy: nextOrderBy });
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, {
pagination: { offset, limit },
@@ -299,6 +327,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
tab.resultSortDirection = direction ?? undefined;
tab.resultSortMode = direction ? mode : undefined;
clearOpenTableDefaultSortMarker(tab);
if (mode === "local") {
if (tab.mode === "data") {
@@ -11,6 +11,7 @@ import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataT
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import { applyOpenTableDefaultSortState, buildOpenTableDefaultSort } from "@/lib/table/tableDefaultSort";
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { canActivateExistingDataTableTab } from "@/lib/tabs/dataTabActivation";
@@ -141,6 +142,8 @@ export function useSidebarDataOpenRuntime() {
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.resultSortedSql = undefined;
tab.openTableDefaultSortApplied = undefined;
tab.openTableDefaultSortOrderBy = undefined;
tab.resultPageSql = undefined;
tab.resultPageLimit = undefined;
tab.resultPageOffset = undefined;
@@ -274,22 +277,48 @@ export function useSidebarDataOpenRuntime() {
if (!config) throw new Error("Connection config not found");
const limit = tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
const shouldRefreshTableMeta = !cachedTableMeta;
const openTableDefaultSortMode = settingsStore.editorSettings.openTableDefaultSortMode;
let tableMetaForQuery = cachedTableMeta;
let shouldRefreshTableMeta = !tableMetaForQuery;
// Dameng metadata calls must remain serialized behind the table query.
const deferTableMetaRefresh = effectiveDbType === "dameng";
if (cachedTableMeta) {
let attemptedDefaultSortMetadataLoad = false;
if (openTableDefaultSortMode !== "none" && !tableMetaForQuery && !deferTableMetaRefresh) {
attemptedDefaultSortMetadataLoad = true;
try {
const loadedMetadata = await loadTableMetadata({
connectionId: node.connectionId,
database: node.database,
schema: querySchema,
tableName: node.label,
tableType,
databaseType: metadataDatabaseType,
driverProfile: config.driver_profile || config.db_type,
catalog: node.catalog,
traceLogger: isDebugLoggingEnabled() ? (event) => openDataLog("debug", "metadata:trace", { sourceTraceId: traceId, ...event }) : undefined,
});
if (!isActive() || !canApplyTableMetadata(tabId)) return;
tableMetaForQuery = tableMetadataToDataTabMeta(loadedMetadata.metadata, tableSchema);
queryStore.setTableMeta(tabId, tableMetaForQuery);
shouldRefreshTableMeta = false;
logPhase("metadata-loaded-for-default-sort", { tabId, columnCount: tableMetaForQuery.columns.length, primaryKeyCount: tableMetaForQuery.primaryKeys.length });
} catch (error) {
openDataLog("warn", "metadata:default-sort:error", { traceId, tabId, elapsed: elapsed(), error });
}
}
if (tableMetaForQuery) {
openDataLog("info", "metadata:cache-hit", {
traceId,
tabId,
columnCount: cachedTableMeta.columns.length,
primaryKeyCount: cachedTableMeta.primaryKeys.length,
columnCount: tableMetaForQuery.columns.length,
primaryKeyCount: tableMetaForQuery.primaryKeys.length,
source: cachedTableMetaSource,
ageMs: Math.round(cachedTableMetaAgeMs),
elapsed: elapsed(),
});
} else if (deferTableMetaRefresh) {
logPhase("metadata-deferred", { tabId });
} else {
} else if (!attemptedDefaultSortMetadataLoad) {
void refreshTableMetaInBackground(tabId);
logPhase("metadata-started", { tabId });
}
@@ -300,8 +329,15 @@ export function useSidebarDataOpenRuntime() {
return;
}
const columns = cachedTableMeta?.columns ?? [];
const primaryKeys = cachedTableMeta?.primaryKeys ?? [];
const columns = tableMetaForQuery?.columns ?? [];
const primaryKeys = tableMetaForQuery?.primaryKeys ?? [];
const defaultSort = buildOpenTableDefaultSort({
mode: openTableDefaultSortMode,
databaseType: effectiveDbType,
identifierQuote: connectionStore.connectionIdentifierQuote?.(node.connectionId),
primaryKeys,
columns: columns.map((column) => column.name),
});
const includeRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, tableType);
const sql = await buildTableSelectSql({
databaseType: effectiveDbType,
@@ -313,6 +349,7 @@ export function useSidebarDataOpenRuntime() {
catalog: node.catalog,
columns: columns.map((column) => column.name),
primaryKeys,
orderBy: defaultSort?.orderBy,
limit,
includeRowId,
});
@@ -339,6 +376,8 @@ export function useSidebarDataOpenRuntime() {
skipEnsureConnected: true,
pagination: { limit, offset: 0 },
});
const currentTab = queryStore.tabs.find((item) => item.id === tabId);
if (currentTab) applyOpenTableDefaultSortState(currentTab, defaultSort);
openDataLog("info", "execute:done", { traceId, tabId, elapsed: elapsed() });
logPhase("execute-tab-sql", { tabId });
if (shouldRefreshTableMeta && deferTableMetaRefresh && canApplyTableMetadata(tabId)) {