fix(grid): sort paginated table data server side
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user