fix(grid): continue Oracle manual transaction cursor for infinite scroll
This commit is contained in:
10 files changed
+410
-30
No files matched your search
@@ -197,7 +197,16 @@ import {
|
||||
tableDataVisiblePreviewRowRange,
|
||||
type ResultScopedRowCache,
|
||||
} from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import {
|
||||
dataGridBottomScrollTop,
|
||||
dataGridInfiniteScrollAppendCompletion,
|
||||
dataGridScrollPosition,
|
||||
didDataGridInfiniteScrollContextChange,
|
||||
isDataGridAtScrollBottom,
|
||||
isDataGridNearScrollBottom,
|
||||
shouldCheckInfiniteScrollAfterScroll,
|
||||
type DataGridScrollPosition,
|
||||
} from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import { resolveDataGridWheelScroll } from "@/lib/dataGrid/dataGridWheel";
|
||||
import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
|
||||
@@ -3533,7 +3542,8 @@ function currentOrderBy(): string | undefined {
|
||||
|
||||
watch(
|
||||
() => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? ""],
|
||||
() => {
|
||||
(values, previousValues) => {
|
||||
if (!didDataGridInfiniteScrollContextChange(values, previousValues)) return;
|
||||
manualTotalRowCount.value = undefined;
|
||||
// Reset infinite-scroll allLoaded when query context changes
|
||||
infiniteScrollAllLoaded = false;
|
||||
@@ -9757,7 +9767,22 @@ watch(
|
||||
preservedDetailsOnNextResult = null;
|
||||
const shouldPreserveTranspose = preserveTransposeOnNextResult.value;
|
||||
preserveTransposeOnNextResult.value = false;
|
||||
if (isDataGridPrefixAppend(previousResult, result)) return;
|
||||
const appendCompletion = dataGridInfiniteScrollAppendCompletion(previousResult, result, {
|
||||
pageSize: pageSize.value,
|
||||
maxRows: infiniteScrollMaxRows.value,
|
||||
});
|
||||
if (appendCompletion) {
|
||||
if (infiniteScrollEnabled.value) {
|
||||
currentPage.value = appendCompletion.loadedPage;
|
||||
lastInfiniteScrollPage = Math.max(0, appendCompletion.loadedPage - 1);
|
||||
infiniteScrollAllLoaded = appendCompletion.allLoaded;
|
||||
infiniteScrollRequestedOffset = undefined;
|
||||
infiniteScrollRequestedLimit = undefined;
|
||||
infiniteScrollLoading.value = false;
|
||||
isInfiniteScrollPaginating.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (getResetScrollAfterResult()) {
|
||||
clearResetScrollAfterResult();
|
||||
resetGridVerticalScroll();
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ describe("DataGridReadonlyTextSelection", () => {
|
||||
it("closes a stale read-only selection when a result is replaced", () => {
|
||||
const resultWatcher = dataGridSource.match(/watch\(\s*\(\) => props\.result,\s*\(result, previousResult\) => \{[\s\S]*?\n\);/)?.[0] ?? "";
|
||||
|
||||
expect(resultWatcher).toContain("if (isDataGridPrefixAppend(previousResult, result)) return;");
|
||||
expect(resultWatcher).toContain("const appendCompletion = dataGridInfiniteScrollAppendCompletion(previousResult, result");
|
||||
expect(resultWatcher).toContain("if (appendCompletion) {");
|
||||
expect(resultWatcher).toContain("closeReadonlyCellTextSelection();");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dataGridBottomScrollTop, isDataGridAtScrollBottom, restoredDataGridScrollLeft } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import { dataGridBottomScrollTop, dataGridInfiniteScrollAppendCompletion, didDataGridInfiniteScrollContextChange, isDataGridAtScrollBottom, restoredDataGridScrollLeft } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
|
||||
describe("data grid bottom anchoring", () => {
|
||||
it("keeps DOM rows anchored when scrollbar padding increases the scroll height", () => {
|
||||
@@ -44,3 +44,36 @@ describe("data grid horizontal restoration", () => {
|
||||
expect(restoredDataGridScrollLeft(320, 500, 600)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("data grid infinite-scroll append completion", () => {
|
||||
const firstPageRows = Array.from({ length: 100 }, (_, index) => [index + 1]);
|
||||
|
||||
it("stops after an incomplete cursor-backed final segment", () => {
|
||||
const completion = dataGridInfiniteScrollAppendCompletion({ rows: firstPageRows, session_id: "oracle-go-4", has_more: true }, { rows: [...firstPageRows, [101], [102], [103], [104]], appended_from_row_count: 100, has_more: false }, { pageSize: 100, maxRows: 100_000 });
|
||||
|
||||
expect(completion).toEqual({ loadedPage: 2, allLoaded: true });
|
||||
});
|
||||
|
||||
it("honors cursor exhaustion when the total is an exact page multiple", () => {
|
||||
const completion = dataGridInfiniteScrollAppendCompletion(
|
||||
{ rows: firstPageRows, session_id: "oracle-go-5", has_more: true },
|
||||
{ rows: [...firstPageRows, ...Array.from({ length: 100 }, (_, index) => [index + 101])], appended_from_row_count: 100, has_more: false },
|
||||
{ pageSize: 100, maxRows: 100_000 },
|
||||
);
|
||||
|
||||
expect(completion).toEqual({ loadedPage: 2, allLoaded: true });
|
||||
});
|
||||
|
||||
it("keeps offset pagination available after a full non-cursor segment", () => {
|
||||
const completion = dataGridInfiniteScrollAppendCompletion({ rows: firstPageRows, has_more: false }, { rows: [...firstPageRows, ...Array.from({ length: 100 }, (_, index) => [index + 101])], appended_from_row_count: 100, has_more: false }, { pageSize: 100, maxRows: 100_000 });
|
||||
|
||||
expect(completion).toEqual({ loadedPage: 2, allLoaded: false });
|
||||
});
|
||||
|
||||
it("does not reset a completed append when equivalent metadata is replaced", () => {
|
||||
const context = ["SELECT COUNT(*) FROM users", "public", "users", "", "app", "connection-1"];
|
||||
|
||||
expect(didDataGridInfiniteScrollContextChange([...context], context)).toBe(false);
|
||||
expect(didDataGridInfiniteScrollContextChange([...context.slice(0, 2), "audit", ...context.slice(3)], context)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1248,7 +1248,7 @@ export async function beginManualTransaction(_connectionId: string, _database: s
|
||||
throw new Error("Manual transaction management is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function executeInManualTransaction(_txnSessionId: string, _sql: string, _database: string, _schema?: string, _maxRows?: number, _tableDataPreview?: boolean): Promise<QueryResult[]> {
|
||||
export async function executeInManualTransaction(_txnSessionId: string, _sql: string, _database: string, _schema?: string, _maxRows?: number, _tableDataPreview?: boolean, _pageSize?: number, _resultSessionId?: string): Promise<QueryResult[]> {
|
||||
throw new Error("Manual transaction management is only available in the desktop app.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1428,7 +1428,7 @@ export async function beginManualTransaction(connectionId: string, database: str
|
||||
return invoke("begin_manual_transaction", { connectionId, database, schema, catalog });
|
||||
}
|
||||
|
||||
export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number, tableDataPreview?: boolean): Promise<QueryResult[]> {
|
||||
export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number, tableDataPreview?: boolean, pageSize?: number, resultSessionId?: string): Promise<QueryResult[]> {
|
||||
return invoke("execute_in_manual_transaction", {
|
||||
txnSessionId,
|
||||
sql,
|
||||
@@ -1436,6 +1436,8 @@ export async function executeInManualTransaction(txnSessionId: string, sql: stri
|
||||
schema,
|
||||
maxRows,
|
||||
tableDataPreview,
|
||||
pageSize,
|
||||
resultSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ export interface DataGridScrollMetrics {
|
||||
export interface DataGridAppendResult {
|
||||
rows: readonly unknown[];
|
||||
appended_from_row_count?: number;
|
||||
session_id?: string | null;
|
||||
has_more?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridInfiniteScrollAppendCompletion {
|
||||
loadedPage: number;
|
||||
allLoaded: boolean;
|
||||
}
|
||||
|
||||
export function dataGridScrollPosition(top: number, left: number): DataGridScrollPosition {
|
||||
@@ -40,6 +47,26 @@ export function isDataGridPrefixAppend(previous: DataGridAppendResult | undefine
|
||||
return previous.rows.every((row, index) => row === next.rows[index]);
|
||||
}
|
||||
|
||||
export function dataGridInfiniteScrollAppendCompletion(previous: DataGridAppendResult | undefined, next: DataGridAppendResult, options: { pageSize: number; maxRows: number }): DataGridInfiniteScrollAppendCompletion | undefined {
|
||||
if (!isDataGridPrefixAppend(previous, next)) return undefined;
|
||||
|
||||
const pageSize = Math.max(1, Math.floor(options.pageSize));
|
||||
const maxRows = Math.max(1, Math.floor(options.maxRows));
|
||||
const appendedFromRowCount = next.appended_from_row_count!;
|
||||
const appendedRowCount = next.rows.length - appendedFromRowCount;
|
||||
const requestedRowCount = Math.min(pageSize, Math.max(0, maxRows - appendedFromRowCount));
|
||||
const cursorExhausted = !!previous?.session_id && previous.has_more === true && next.has_more === false;
|
||||
|
||||
return {
|
||||
loadedPage: Math.max(1, Math.ceil(next.rows.length / pageSize)),
|
||||
allLoaded: next.rows.length >= maxRows || appendedRowCount < requestedRowCount || cursorExhausted,
|
||||
};
|
||||
}
|
||||
|
||||
export function didDataGridInfiniteScrollContextChange(current: readonly string[], previous: readonly string[] | undefined): boolean {
|
||||
return !previous || current.length !== previous.length || current.some((value, index) => value !== previous[index]);
|
||||
}
|
||||
|
||||
export function isDataGridAtScrollBottom(metrics: DataGridScrollMetrics, tolerance = 1): boolean {
|
||||
const maxScrollTop = Math.max(0, metrics.scrollHeight - metrics.clientHeight);
|
||||
return maxScrollTop - metrics.scrollTop <= tolerance;
|
||||
|
||||
@@ -812,6 +812,65 @@ describe("queryStore hidden primary key editing", () => {
|
||||
expect(store.tabs.find((tab) => tab.id === tabId)?.result?.large_value_cells).toEqual([{ row_index: 0, column_index: 1, original_bytes: 81920 }]);
|
||||
});
|
||||
|
||||
it("continues the Oracle cursor for page 2 in a manual transaction", async () => {
|
||||
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 });
|
||||
analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "complex-query" });
|
||||
prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({
|
||||
sqlToExecute: options.sql,
|
||||
pageSql: options.sql,
|
||||
pageLimit: options.pagination.limit,
|
||||
pageOffset: options.pagination.offset,
|
||||
countSql: undefined,
|
||||
useAgentResultSession: true,
|
||||
}));
|
||||
executeInManualTransaction
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
columns: ["ID"],
|
||||
rows: Array.from({ length: 100 }, (_, index) => [index + 1]),
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
session_id: "oracle-go-1",
|
||||
has_more: true,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
columns: ["ID"],
|
||||
rows: Array.from({ length: 100 }, (_, index) => [index + 101]),
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
has_more: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query");
|
||||
// Tabs default to auto-commit; exercise the explicit manual-transaction path.
|
||||
store.setAutoCommit(tabId, false);
|
||||
const sql = "SELECT ID FROM APP.EVENTS ORDER BY ID";
|
||||
|
||||
await store.executeTabSql(tabId, sql);
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.autoCommit).toBe(false);
|
||||
expect(executeInManualTransaction).toHaveBeenNthCalledWith(1, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, undefined);
|
||||
expect(tab.result?.rows).toHaveLength(100);
|
||||
|
||||
await store.executeTabSql(tabId, sql, {
|
||||
resultBaseSql: sql,
|
||||
pagination: { limit: 100, offset: 100, sessionId: "oracle-go-1" },
|
||||
appendResult: { maxRows: 10_000 },
|
||||
preserveResultDuringExecution: true,
|
||||
preserveTotalRowCountDuringExecution: true,
|
||||
replaceActiveResultInGroup: true,
|
||||
});
|
||||
|
||||
expect(executeInManualTransaction).toHaveBeenNthCalledWith(2, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, "oracle-go-1");
|
||||
expect(tab.result?.rows).toHaveLength(200);
|
||||
expect(tab.result?.rows[100]).toEqual([101]);
|
||||
});
|
||||
|
||||
it("does not start an Oracle manual transaction after cancellation during metadata loading", async () => {
|
||||
const columnsGate = deferred<Awaited<ReturnType<typeof getColumns>>>();
|
||||
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 });
|
||||
|
||||
@@ -5139,10 +5139,14 @@ export const useQueryStore = defineStore("query", () => {
|
||||
executionPromise = (async () => {
|
||||
const txnSessionId = tab.txnSessionId;
|
||||
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
|
||||
const executeInTransaction = (sessionId: string) =>
|
||||
useAgentResultSession
|
||||
? api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, pageLimit, options?.pagination?.sessionId)
|
||||
: api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
|
||||
try {
|
||||
return await api.executeInManualTransaction(txnSessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
|
||||
return await executeInTransaction(txnSessionId);
|
||||
} catch (error) {
|
||||
if (manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
|
||||
if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
|
||||
manualTransactionRecoveryAttempted = true;
|
||||
tab.txnSessionId = undefined;
|
||||
tab.txnAutoRolledBack = true;
|
||||
@@ -5150,7 +5154,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const refreshedSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
|
||||
tab.txnSessionId = refreshedSessionId;
|
||||
queryExecutionLog("info", "manual-txn:restarted", { traceId, txnSessionId: refreshedSessionId, elapsed: elapsed() });
|
||||
return api.executeInManualTransaction(refreshedSessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
|
||||
return executeInTransaction(refreshedSessionId);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user