fix(grid): sort paginated table data server side

This commit is contained in:
jearhe authored and GitHub committed 2026-08-31 23:45:40 +08:00
1 parent f075caef21
commit 40a806a24a
5 files changed
+251 -6

No files matched your search

@@ -3818,7 +3818,8 @@ function currentWhereInput(): string | undefined {
}
function currentOrderBy(): string | undefined {
return orderByInput.value.trim() || (sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined);
const structuredOrderBy = sortMode.value === "database" && sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined;
return orderByInput.value.trim() || structuredOrderBy;
}
function executeServerPageJump(targetPage: number, updateCurrentPage = false) {
@@ -32,4 +32,11 @@ describe("DataGrid infinite-scroll sorting", () => {
expect(sortSource).toContain("} else {\n currentPage.value = 1;\n resetGridVerticalScroll(true);\n }");
expect(sortSource.match(/emit\("sort"/g)).toHaveLength(1);
});
it("does not expose an explicit local sort as a database order for refresh or pagination", () => {
const orderSource = functionSource("currentOrderBy", "executeServerPageJump");
expect(orderSource).toContain('sortMode.value === "database"');
expect(orderSource).toContain("return orderByInput.value.trim() || structuredOrderBy;");
});
});
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
buildTableSelectSql: vi.fn(),
buildSortedQuerySql: vi.fn(),
executeTabSql: vi.fn(),
sortTabResultLocally: vi.fn(),
getConfig: vi.fn(),
setExecuting: vi.fn(),
updateSql: vi.fn(),
@@ -51,6 +52,7 @@ vi.mock("@/stores/connectionStore", () => ({
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({
executeTabSql: mocks.executeTabSql,
sortTabResultLocally: mocks.sortTabResultLocally,
activeResultExecutionTarget: mocks.activeResultExecutionTarget,
setExecuting: mocks.setExecuting,
updateSql: mocks.updateSql,
@@ -642,4 +644,181 @@ describe("useDataGridActions", () => {
}),
);
});
it("database-sorts a paginated table from the first page instead of reordering the current page", async () => {
const fullRows = [...Array.from({ length: 10 }, (_, index) => [50 + index]), ...Array.from({ length: 20 }, (_, index) => [index + 1])];
const tab = tableDataTab({
result: { columns: ["sort_value"], rows: fullRows.slice(0, 10), affected_rows: 30, execution_time_ms: 1 },
resultPageLimit: 10,
resultPageOffset: 10,
whereInput: "status = 'active'",
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
primaryKeys: [],
},
});
mocks.infiniteScroll = false;
mocks.buildTableSelectSql.mockImplementationOnce(async (options) => {
expect(options).toMatchObject({ orderBy: '"sort_value" ASC', whereInput: "status = 'active'", limit: 10, offset: 0 });
return 'SELECT "sort_value" FROM public.users WHERE (status = \'active\') ORDER BY "sort_value" ASC LIMIT 10 OFFSET 0';
});
mocks.executeTabSql.mockImplementationOnce(async (_id, _sql, options) => {
expect(options.pagination).toEqual({ limit: 10, offset: 0 });
const sortedRows = [...fullRows].sort((left, right) => Number(left[0]) - Number(right[0]));
tab.result = { ...tab.result!, rows: sortedRows.slice(0, 10) };
tab.resultPageLimit = options.pagination.limit;
tab.resultPageOffset = options.pagination.offset;
});
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, "asc", "status = 'active'");
expect(tab.result?.rows.map((row) => row[0])).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(tab.orderByInput).toBe('"sort_value" ASC');
expect(mocks.sortTabResultLocally).not.toHaveBeenCalled();
expect(mocks.executeTabSql).toHaveBeenCalledWith(
"tab-1",
expect.any(String),
expect.objectContaining({
pagination: { limit: 10, offset: 0 },
preserveTotalRowCountDuringExecution: true,
}),
);
});
it.each([
["asc", '"sort_value" ASC'],
["desc", '"sort_value" DESC'],
] as const)("immediately executes paginated table %s sorting with its page size", async (direction, orderBy) => {
const tab = tableDataTab({
resultPageLimit: 7,
resultPageOffset: 14,
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
primaryKeys: [],
},
});
mocks.infiniteScroll = false;
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted");
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, direction, "status = 'active'", "database");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy, whereInput: "status = 'active'", limit: 7, offset: 0 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 7, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
expect(tab.resultSortMode).toBe("database");
});
it("clears paginated table sorting by querying the first page without the generated order", async () => {
const tab = tableDataTab({
resultSortColumn: "sort_value",
resultSortColumnIndex: 0,
resultSortDirection: "desc",
resultSortMode: "database",
orderByInput: '"sort_value" DESC',
resultPageLimit: 10,
resultPageOffset: 20,
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
primaryKeys: [],
},
});
mocks.infiniteScroll = false;
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT unsorted");
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, null, "status = 'active'", "database");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined, whereInput: "status = 'active'", limit: 10, offset: 0 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT unsorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
expect(tab.resultSortColumn).toBeUndefined();
expect(tab.resultSortDirection).toBeUndefined();
expect(tab.resultSortMode).toBeUndefined();
expect(tab.orderByInput).toBeUndefined();
});
it("keeps the database order and WHERE clause when fetching the next page", async () => {
const tab = tableDataTab({
result: { columns: ["sort_value"], rows: Array.from({ length: 10 }, (_, index) => [index + 1]), affected_rows: 30, execution_time_ms: 1 },
resultSortColumn: "sort_value",
resultSortColumnIndex: 0,
resultSortDirection: "asc",
resultSortMode: "database",
orderByInput: '"sort_value" ASC',
resultPageLimit: 10,
resultPageOffset: 0,
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
primaryKeys: [],
},
});
mocks.infiniteScroll = false;
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted page 2");
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(10, 10, "status = 'active'");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"sort_value" ASC', whereInput: "status = 'active'", limit: 10, offset: 10 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted page 2", expect.objectContaining({ pagination: { offset: 10, limit: 10 }, preserveTotalRowCountDuringExecution: true }));
});
it("keeps explicit local sorting local without issuing a database request", async () => {
const tab = tableDataTab({
result: { columns: ["sort_value"], rows: [[50], [60], [70]], affected_rows: 30, execution_time_ms: 1 },
resultPageLimit: 10,
resultPageOffset: 10,
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
primaryKeys: [],
},
});
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, "asc", "status = 'active'", "local");
expect(mocks.sortTabResultLocally).toHaveBeenCalledWith("tab-1", "sort_value", 0, "asc");
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
expect(tab.orderByInput).toBeUndefined();
});
it("keeps SQL query result database sorting on the first page when the result is paginated", async () => {
const tab = {
id: "tab-1",
connectionId: "postgres-1",
database: "app",
title: "Query",
sql: "SELECT sort_value FROM users",
resultBaseSql: "SELECT sort_value FROM users",
resultPageLimit: 10,
resultPageOffset: 20,
result: { columns: ["sort_value"], rows: [[50]], affected_rows: 30, execution_time_ms: 1 },
mode: "query",
isDirty: false,
isExecuting: false,
isCancelling: false,
isExplaining: false,
} as QueryTab;
mocks.buildSortedQuerySql.mockResolvedValueOnce({ ok: true, sql: "SELECT sorted" });
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, "desc", undefined, "database");
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
});
});
@@ -58,6 +58,15 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
return quoteTableDataIdentifier(effectiveDatabaseTypeForConnection(config), name, connectionStore.connectionIdentifierQuote?.(tab.connectionId));
}
function tableDataPageLimit(tab: QueryTab): number {
return tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
}
function resultSortPagination(tab: QueryTab): { limit: number; offset: number } | undefined {
const limit = tab.resultPageLimit;
return typeof limit === "number" && limit > 0 ? { limit, offset: 0 } : undefined;
}
function buildTableSql(tab: QueryTab, options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {}): Promise<string> {
const config = connectionStore.getConfig(tab.connectionId);
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
@@ -68,7 +77,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
// 结果(可能是失败结果的 ["Error"]),进入 SQL 会生成非法投影;
// 真实列缺失时省略 columns 让 builder 生成 SELECT *
const realColumns = tab.tableMeta?.columns.length ? tab.tableMeta.columns : undefined;
const limit = options.limit ?? tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
const limit = options.limit ?? tableDataPageLimit(tab);
return buildTableSelectSql({
databaseType: effectiveDbType,
driverProfile: config?.driver_profile,
@@ -435,7 +444,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
if (!tableMetaForDataTab(tab)) return;
tab.whereInput = whereInput ?? "";
const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy });
const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy: orderBy ?? tab.orderByInput });
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, {
pagination: { offset, limit },
@@ -468,20 +477,29 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const config = connectionStore.getConfig(tab.connectionId);
const quotedColumn = quoteIdent(tab, column);
const orderBy = direction ? `${config?.db_type === "neo4j" ? `n.${quotedColumn}` : quotedColumn} ${direction.toUpperCase()}` : undefined;
const sql = await buildTableSql(tab, { orderBy, whereInput });
const limit = tableDataPageLimit(tab);
const pagination = { limit, offset: 0 };
tab.orderByInput = orderBy;
const sql = await buildTableSql(tab, { orderBy, whereInput, limit, offset: pagination.offset });
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, { preserveResultDuringExecution: true });
await queryStore.executeTabSql(tab.id, sql, {
pagination,
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
});
return;
}
const baseSql = queryResultBaseSql(tab);
if (!baseSql.trim()) return;
const pagination = resultSortPagination(tab);
if (!direction) {
await queryStore.executeTabSql(tab.id, baseSql, {
...activeQueryTargetOptions(tab),
resultBaseSql: baseSql,
resultSortedSql: undefined,
...(pagination ? { pagination } : {}),
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
replaceActiveResultInGroup: true,
@@ -502,6 +520,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
...activeQueryTargetOptions(tab),
resultBaseSql: baseSql,
resultSortedSql: sortedSql,
...(pagination ? { pagination } : {}),
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
replaceActiveResultInGroup: true,
@@ -531,6 +550,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
...activeQueryTargetOptions(tab),
resultBaseSql: baseSql,
resultSortedSql: built.sql,
...(pagination ? { pagination } : {}),
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
replaceActiveResultInGroup: true,
@@ -546,6 +566,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
column,
direction,
},
...(pagination ? { pagination } : {}),
preserveResultDuringExecution: true,
preserveTotalRowCountDuringExecution: true,
replaceActiveResultInGroup: true,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS, elasticsearchCursorPageJumpRequestCount } from "@/lib/dataGrid/dataGridPagination";
import { ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS, elasticsearchCursorPageJumpRequestCount, hasCompleteLocalDataGridResult } from "@/lib/dataGrid/dataGridPagination";
describe("Elasticsearch cursor page jumps", () => {
it("counts only missing forward cursors", () => {
@@ -17,3 +17,40 @@ describe("Elasticsearch cursor page jumps", () => {
expect(elasticsearchCursorPageJumpRequestCount(1, 101)).toBe(ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS);
});
});
describe("complete local DataGrid results", () => {
it("recognizes complete results without treating a partial page as complete", () => {
expect(
hasCompleteLocalDataGridResult({
isResultsContext: true,
rowCount: 3,
pageLimit: 10,
pageOffset: 0,
truncated: false,
hasMore: false,
}),
).toBe(true);
expect(
hasCompleteLocalDataGridResult({
isResultsContext: true,
rowCount: 10,
pageLimit: 10,
pageOffset: 0,
totalRowCount: 25,
truncated: false,
hasMore: false,
}),
).toBe(false);
expect(
hasCompleteLocalDataGridResult({
isResultsContext: true,
rowCount: 20,
pageLimit: 10,
pageOffset: 10,
totalRowCount: 20,
truncated: false,
hasMore: false,
}),
).toBe(false);
});
});