From a10bd55a73ccd6ef4ae00f1694d59267940f9780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sat, 17 Oct 2026 13:23:39 +0800 Subject: [PATCH] fix(mysql): disable next page via read only probe --- apps/desktop/src/components/grid/DataGrid.vue | 24 +++++--- .../table/tableDataPageProbe.spec.ts | 19 +++++++ apps/desktop/src/lib/backend/tauri.ts | 2 + .../src/lib/dataGrid/dataGridPagination.ts | 15 ++++- .../src/lib/query/queryPaginationResult.ts | 16 ++++++ .../src/lib/table/tableDataPageProbe.ts | 17 ++++++ apps/desktop/src/lib/tabs/tabResultCache.ts | 4 ++ .../queryStore.tableDataRefresh.spec.ts | 46 ++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 55 ++++++++++++++++--- apps/desktop/src/types/database.ts | 2 + crates/dbx-sql-data/src/query_result_sql.rs | 34 ++++++++++++ packages/app-tests/useDataGridActions.test.ts | 12 ++++ 12 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/table/tableDataPageProbe.spec.ts create mode 100644 apps/desktop/src/lib/table/tableDataPageProbe.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2d4674c723..131a12193e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -3616,7 +3616,7 @@ watch( finishOrContinueLoadAllRun(requestedOffset, requestedLimit); return; } - if (props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) { + if (props.result.page_has_more === false || props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) { infiniteScrollAllLoaded = true; } if (shouldSelectLastRow) selectAndRevealLastLoadedRow(); @@ -3710,11 +3710,14 @@ const hasCompleteLocalResult = computed(() => totalRowCount: serverKnownTotalRowCount.value, truncated: props.result.truncated, hasMore: props.result.has_more, + pageHasMore: props.result.page_has_more, }), ); const canGoNextPage = computed(() => { return canGoNextDataGridPage({ hasMore: props.result.has_more, + pageHasMore: props.result.page_has_more, + maxRows: paginationMaxRows.value, rowCount: props.result.rows.length, pageSize: pageSize.value, pageOffset: props.pageOffset, @@ -3725,13 +3728,16 @@ const canGoNextPage = computed(() => { }); const maximumPage = computed(() => { if (allRowsLoaded.value) return 1; + if (props.result.page_has_more === false) return currentPage.value; const total = hasKnownPaginationTotalRowCount.value ? paginationTotalRowCount.value : undefined; + if (props.result.page_has_more === true && canGoNextPage.value && typeof total === "number" && total <= (props.pageOffset ?? 0) + props.result.rows.length) return undefined; if (typeof total === "number" && Number.isFinite(total) && total >= 0) return Math.max(1, Math.ceil(total / pageSize.value)); return canGoNextPage.value ? undefined : currentPage.value; }); const canFetchNextInfiniteScrollSegment = computed(() => canFetchNextDataGridSegment({ hasMore: props.result.has_more, + pageHasMore: props.result.page_has_more, loadedRowCount: props.result.rows.length, pageSize: pageSize.value, totalRowCount: hasKnownPaginationTotalRowCount.value ? paginationTotalRowCount.value : undefined, @@ -4017,17 +4023,19 @@ function selectAndRevealLastLoadedRow() { } // SQL-page counts can come from another session. Explicit load-all must -// observe a short tail on the original execution path, even at a cached total. +// observe exhaustion on the original execution path, even at a cached total. const loadsSqlPages = computed(() => isResultsContext.value && !!props.pageSql); -const sqlPageExhausted = computed(() => - hasShortDataGridSqlPage({ +const sqlPageExhausted = computed(() => { + if (typeof props.result.page_has_more === "boolean") return !props.result.page_has_more; + if (props.result.truncated === true) return false; + return hasShortDataGridSqlPage({ rowCount: props.result.rows.length, pageOffset: props.pageOffset, pageLimit: props.pageLimit, executedPageOffset: props.executedPageOffset, executedPageLimit: props.executedPageLimit, - }), -); + }); +}); function nextLoadAllSegment() { if (loadsSqlPages.value) { if (sqlPageExhausted.value) return null; @@ -11825,7 +11833,7 @@ watch( if (infiniteScrollEnabled.value || loadAllRowsLoopActive) { currentPage.value = appendCompletion.loadedPage; lastInfiniteScrollPage = Math.max(0, appendCompletion.loadedPage - 1); - infiniteScrollAllLoaded = appendCompletion.allLoaded; + infiniteScrollAllLoaded = appendCompletion.allLoaded || result.page_has_more === false; infiniteScrollRequestedOffset = undefined; infiniteScrollRequestedLimit = undefined; infiniteScrollLoading.value = false; @@ -11858,7 +11866,7 @@ watch( loadAllRowsActive.value = false; loadAllRowsLoopActive = false; if (reloadedFromFirstRow) { - if (props.result.has_more === true && !props.result.truncated) { + if (props.result.page_has_more === true || (props.result.has_more === true && !props.result.truncated)) { loadAllRowsActive.value = true; loadAllRowsLoopActive = true; nextTick(() => { diff --git a/apps/desktop/src/lib/__tests__/table/tableDataPageProbe.spec.ts b/apps/desktop/src/lib/__tests__/table/tableDataPageProbe.spec.ts new file mode 100644 index 0000000000..438b9a1e10 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/tableDataPageProbe.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { mysqlTablePageBaseSql } from "@/lib/table/tableDataPageProbe"; + +describe("MySQL table data page probe", () => { + it("preserves filters, sorting and nested limits when removing the generated page", () => { + const base = "SELECT `limit`, id FROM apis WHERE note = 'LIMIT 55' AND id IN (SELECT id FROM allowed LIMIT 3) ORDER BY id"; + expect(mysqlTablePageBaseSql(`${base} LIMIT 55 OFFSET 110;`, 55, 110)).toBe(base); + expect(mysqlTablePageBaseSql(`${base} LIMIT 55;`, 55, 0)).toBe(base); + }); + + it("keeps MySQL escaped strings and comments out of the page clause", () => { + const base = "SELECT id FROM apis WHERE note = 'it\\'s LIMIT 55' /* LIMIT 55 */ ORDER BY id"; + expect(mysqlTablePageBaseSql(`${base} LIMIT 55;`, 55, 0)).toBe(base); + }); + + it.each(["SELECT * FROM apis LIMIT 54;", "SELECT * FROM apis LIMIT 55 OFFSET 55;", "SELECT * FROM apis LIMIT 55 FOR UPDATE;", "SELECT * FROM apis LIMIT 55; SELECT 1;", "SELECT * FROM apis WHERE note = 'LIMIT 55';"])("leaves unmatched bounds or unsupported suffixes unchanged: %s", (sql) => { + expect(mysqlTablePageBaseSql(sql, 55, 0)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 9aeb5fc606..8b8e0fa1aa 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -550,6 +550,8 @@ export interface QueryPaginationExecutionPlanOptions { export interface QueryPaginationExecutionPlan { sqlToExecute: string; + /** Optional N+1 SQL page approved by the backend's strict read-only proof. */ + pageProbeSql?: string; pageSql?: string; pageLimit?: number; pageOffset?: number; diff --git a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts index 2e3411e695..8a1cbe3359 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridPagination.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridPagination.ts @@ -2,6 +2,8 @@ import type { DatabaseType } from "@/types/database"; export interface CanGoNextDataGridPageOptions { hasMore?: boolean; + pageHasMore?: boolean; + maxRows?: number; rowCount: number; pageSize: number; pageOffset?: number; @@ -22,10 +24,12 @@ export interface CompleteLocalDataGridResultOptions { totalRowCount?: number; truncated?: boolean; hasMore?: boolean; + pageHasMore?: boolean; } export interface CanFetchNextDataGridSegmentOptions { hasMore?: boolean; + pageHasMore?: boolean; loadedRowCount: number; pageSize: number; totalRowCount?: number; @@ -112,9 +116,10 @@ export function reconcileDataGridExactTotalWithObservedPage(options: ReconcileDa } export function hasCompleteLocalDataGridResult(options: CompleteLocalDataGridResultOptions): boolean { - if (!options.isResultsContext || options.truncated === true || options.hasMore === true) return false; + if (!options.isResultsContext || options.truncated === true || options.hasMore === true || options.pageHasMore === true) return false; if (options.pageLimit === undefined) return true; if ((options.pageOffset ?? 0) !== 0) return false; + if (options.pageHasMore === false) return true; const pageLimit = Math.max(1, options.pageLimit); if (options.rowCount < pageLimit) return true; @@ -125,10 +130,15 @@ export function hasCompleteLocalDataGridResult(options: CompleteLocalDataGridRes export function canGoNextDataGridPage(options: CanGoNextDataGridPageOptions): boolean { if (options.allRowsLoaded === true) return false; - if (options.hasMore === true) return true; const pageSize = Math.max(1, options.pageSize); const currentOffset = typeof options.pageOffset === "number" && options.pageOffset >= 0 ? options.pageOffset : Math.max(0, (options.currentPage ?? 1) - 1) * pageSize; + // Lookahead describes the source; discrete navigation still respects the row cap. + if (typeof options.pageHasMore === "boolean") { + if (options.maxRows !== undefined && currentOffset + pageSize >= options.maxRows) return false; + return options.pageHasMore; + } + if (options.hasMore === true) return true; const totalRowCount = options.totalRowCount; if (typeof totalRowCount === "number" && Number.isFinite(totalRowCount) && totalRowCount >= 0) { @@ -139,6 +149,7 @@ export function canGoNextDataGridPage(options: CanGoNextDataGridPageOptions): bo } export function canFetchNextDataGridSegment(options: CanFetchNextDataGridSegmentOptions): boolean { + if (typeof options.pageHasMore === "boolean") return options.pageHasMore; if (options.hasMore === true) return true; const totalRowCount = options.totalRowCount; diff --git a/apps/desktop/src/lib/query/queryPaginationResult.ts b/apps/desktop/src/lib/query/queryPaginationResult.ts index 784882fef3..140ecfbc49 100644 --- a/apps/desktop/src/lib/query/queryPaginationResult.ts +++ b/apps/desktop/src/lib/query/queryPaginationResult.ts @@ -1,5 +1,21 @@ import type { QueryResult } from "@/types/database"; +/** The extra row proves availability but must never enter the displayed result. */ +export function trimQueryPageProbe(result: T, pageLimit: number): T { + if (result.execution_error || result.server_message || result.columns.length === 0) return result; + const hasNextPage = result.rows.length > pageLimit; + return { + ...result, + // A truncated response can prove more rows exist, but cannot prove exhaustion. + page_has_more: hasNextPage ? true : result.truncated === true || result.has_more === true ? undefined : false, + rows: result.rows.slice(0, pageLimit), + spatial_values: result.spatial_values?.slice(0, pageLimit), + large_value_cells: result.large_value_cells?.filter((cell) => cell.row_index < pageLimit), + // Keep discarded preview refs owned by the result until its normal cleanup. + large_value_refs: result.large_value_refs, + }; +} + /** Remove only the trailing helper identified by the pagination plan. */ export function stripPaginationRowNumber(result: QueryResult, column: string | undefined): QueryResult { const index = result.columns.length - 1; diff --git a/apps/desktop/src/lib/table/tableDataPageProbe.ts b/apps/desktop/src/lib/table/tableDataPageProbe.ts new file mode 100644 index 0000000000..072bc6aff8 --- /dev/null +++ b/apps/desktop/src/lib/table/tableDataPageProbe.ts @@ -0,0 +1,17 @@ +import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; + +/** Remove only the trailing page clause generated for a MySQL data tab. */ +export function mysqlTablePageBaseSql(sql: string, limit: number, offset: number): string | undefined { + const tokens = tokenizeSqlSemantic(sql, "mysql", { + mysqlDashCommentRequiresWhitespace: true, + mysqlBackslashEscape: true, + mysqlDoubleQuoteIsString: true, + }); + const pageClause = tokens.find((token) => token.depth === 0 && token.kind === "word" && token.normalized === "limit"); + if (!pageClause) return undefined; + // Match the builder's literal page bounds, not LIMIT text inside a string, + // identifier, comment or subquery. Unknown suffixes keep the original path. + const match = /^LIMIT\s+(\d+)(?:\s+OFFSET\s+(\d+))?\s*;?\s*$/i.exec(sql.slice(pageClause.span.start)); + if (!match || Number(match[1]) !== limit || Number(match[2] ?? 0) !== offset) return undefined; + return sql.slice(0, pageClause.span.start).trimEnd(); +} diff --git a/apps/desktop/src/lib/tabs/tabResultCache.ts b/apps/desktop/src/lib/tabs/tabResultCache.ts index 63a1c47004..32196c44be 100644 --- a/apps/desktop/src/lib/tabs/tabResultCache.ts +++ b/apps/desktop/src/lib/tabs/tabResultCache.ts @@ -82,6 +82,7 @@ interface ColumnarQueryResult { timing_page_count?: number; truncated?: boolean; has_more?: boolean; + page_has_more?: boolean; sourceLabel?: string; sourceLabelKind?: QueryResult["sourceLabelKind"]; sourceQualifier?: string; @@ -380,6 +381,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin truncated: result.truncated, session_id: undefined, has_more: result.has_more, + page_has_more: result.page_has_more, sourceLabel: result.sourceLabel, sourceLabelKind: result.sourceLabelKind, sourceQualifier: result.sourceQualifier, @@ -445,6 +447,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult timing_page_count: result.timing_page_count, truncated: result.truncated, has_more: result.has_more, + page_has_more: result.page_has_more, sourceLabel: result.sourceLabel, sourceLabelKind: result.sourceLabelKind, sourceQualifier: result.sourceQualifier, @@ -484,6 +487,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul truncated: result.truncated, session_id: undefined, has_more: result.has_more, + page_has_more: result.page_has_more, sourceLabel: result.sourceLabel, sourceLabelKind: result.sourceLabelKind, sourceQualifier: result.sourceQualifier, diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts index 50fcd3b7e1..428a58c5c4 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ getConnectionConfig: vi.fn(), saveOpenTabsState: vi.fn(), loadTableMetadata: vi.fn(), + prepareQueryPaginationExecutionPlan: vi.fn(), metadataGeneration: 0, })); @@ -20,6 +21,7 @@ vi.mock("@/lib/backend/api", () => ({ closeClientConnectionSession: mocks.closeClientConnectionSession, closeQuerySession: mocks.closeQuerySession, executeMulti: mocks.executeMulti, + prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan, saveOpenTabsState: mocks.saveOpenTabsState, })); @@ -64,6 +66,7 @@ describe("queryStore table data refresh", () => { clearDataGridSortBuilderMemoryCache(); setActivePinia(createPinia()); mocks.metadataGeneration = 0; + mocks.prepareQueryPaginationExecutionPlan.mockResolvedValue({ useAgentResultSession: false }); mocks.getConnectionConfig.mockReturnValue({ id: "pg-1", name: "Postgres", @@ -82,6 +85,49 @@ describe("queryStore table data refresh", () => { ]); }); + it.each([ + { offset: 0, returnedRows: 55, hasMore: false }, + { offset: 0, returnedRows: 56, hasMore: true }, + { offset: 55, returnedRows: 55, hasMore: false }, + ])("probes MySQL data pages without publishing the extra row: %j", async ({ offset, returnedRows, hasMore }) => { + mocks.getConnectionConfig.mockReturnValue({ id: "mysql-1", db_type: "mysql", database: "app" }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const id = store.createTab("mysql-1", "app", "apis", "data"); + const baseSql = "SELECT id FROM apis WHERE id > 0 ORDER BY id"; + const sql = `${baseSql} LIMIT 55${offset ? ` OFFSET ${offset}` : ""};`; + const probeSql = `${baseSql} LIMIT 56 OFFSET ${offset};`; + mocks.prepareQueryPaginationExecutionPlan.mockResolvedValue({ pageProbeSql: probeSql, useAgentResultSession: false }); + mocks.executeMulti.mockResolvedValue([ + { + columns: ["id"], + rows: Array.from({ length: returnedRows }, (_, index) => [offset + index + 1]), + affected_rows: 0, + execution_time_ms: 1, + has_more: false, + }, + ]); + + await store.executeTabSql(id, sql, { pagination: { limit: 55, offset } }); + + expect(mocks.prepareQueryPaginationExecutionPlan).toHaveBeenCalledWith( + expect.objectContaining({ + sql: baseSql, + queryBaseSql: sql, + pagination: { limit: 55, offset }, + useAgentCursor: false, + }), + ); + expect(mocks.executeMulti.mock.calls[0]?.[2]).toBe(probeSql); + const tab = store.tabs.find((candidate) => candidate.id === id)!; + expect(tab.result?.rows).toHaveLength(55); + expect(tab.result?.rows[0]).toEqual([offset + 1]); + expect(tab.result?.page_has_more).toBe(hasMore); + expect(tab.resultPageLimit).toBe(55); + expect(tab.resultPageOffset).toBe(offset); + expect(tab.resultTotalRowCount).toBe(hasMore ? undefined : offset + 55); + }); + it("loads a duplicated data tab with its filter, database sort and page without sharing results", async () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f4352fd990..396c693f5d 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -100,7 +100,8 @@ import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connect import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; import { queryResultNameFromPreamble, queryResultSourceLabel, queryResultSourceNameParts } from "@/lib/sql/queryResultSource"; import { sqlServerCountUsesLocalTempTable } from "@/lib/query/queryResultCountSession"; -import { stripPaginationRowNumber } from "@/lib/query/queryPaginationResult"; +import { stripPaginationRowNumber, trimQueryPageProbe } from "@/lib/query/queryPaginationResult"; +import { mysqlTablePageBaseSql } from "@/lib/table/tableDataPageProbe"; import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection"; import { appendLargeValueCells, canUseTableDataLargeValuePreview, remapLargeValueCells, supportsTableDataLargeValuePreview, tableDataLargeValuePreviewOptions, TABLE_DATA_RESULT_MAX_BYTES } from "@/lib/dataGrid/dataGridLargeValues"; import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort"; @@ -384,7 +385,8 @@ function markQueryResultsRowsRaw(results: QueryResult[]): QueryResult[] { } function exactTotalFromIncompletePage(result: QueryResult, pageLimit: number | undefined, pageOffset: number | undefined, useAgentResultSession: boolean | undefined): number | undefined { - if (typeof pageLimit !== "number" || result.rows.length >= pageLimit || result.truncated === true) return undefined; + if (typeof pageLimit !== "number" || result.truncated === true || result.page_has_more === true) return undefined; + if (result.rows.length >= pageLimit && result.page_has_more !== false) return undefined; // Cursor-backed drivers must explicitly confirm exhaustion; an omitted // has_more value may come from an older driver or an exhausted row cap. if (useAgentResultSession && result.has_more !== false) return undefined; @@ -447,6 +449,7 @@ export function appendQueryResultSegment(previous: QueryResult, segment: QueryRe client_result_ms: previous.client_result_ms !== undefined && segment.client_result_ms !== undefined ? previous.client_result_ms + segment.client_result_ms : undefined, timing_page_count: (previous.timing_page_count ?? 1) + (segment.timing_page_count ?? 1), has_more: previous.rows.length + appendedRowCount >= maxRows ? false : segment.has_more, + page_has_more: appendedRowCount < segment.rows.length ? true : segment.page_has_more, }); } @@ -7706,6 +7709,7 @@ export const useQueryStore = defineStore("query", () => { let pageSql: string | undefined; let requestedPageLimit: number | undefined; let pageLimit: number | undefined; + let pageProbeLimit: number | undefined; let pageOffset: number | undefined; let countSql: string | undefined; let exactQueryRowBound: number | undefined; @@ -8514,8 +8518,12 @@ export const useQueryStore = defineStore("query", () => { if (sqlServerUseScript && !canPaginateSqlServerUseScript) { sqlToExecute = sqlBeforePagination; } else { - sqlToExecute = sqlServerUseScript ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.sqlToExecute) : plan.sqlToExecute; - pageSql = sqlServerUseScript && plan.pageSql ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.pageSql) : plan.pageSql; + // Only the backend's strict read-only proof may enable an extra row. + // Older backends omit pageProbeSql and retain their original behavior. + const probeSql = effectiveDbType === "mysql" && !plan.useAgentResultSession && plan.pageSql && typeof plan.pageLimit === "number" ? plan.pageProbeSql : undefined; + sqlToExecute = sqlServerUseScript ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.sqlToExecute) : (probeSql ?? plan.sqlToExecute); + pageSql = sqlServerUseScript && plan.pageSql ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.pageSql) : (probeSql ?? plan.pageSql); + pageProbeLimit = probeSql && typeof plan.pageLimit === "number" ? plan.pageLimit + 1 : undefined; pageLimit = plan.pageLimit; pageOffset = plan.pageOffset; countSql = sqlServerUseScript && plan.countSql ? replaceSqlServerLeadingUseQuery(queryBaseSql, sqlServerUseScript, plan.countSql) : plan.countSql; @@ -8548,6 +8556,25 @@ export const useQueryStore = defineStore("query", () => { pageLimit = pagination.limit; pageOffset = pagination.offset; useAgentResultSession = usesAgentCursorForTableData(conn?.db_type, conn?.driver_profile); + if (effectiveDbType === "mysql" && !useAgentCursor && !useAgentResultSession) { + // Data tabs already contain the generated LIMIT/OFFSET. Remove that + // page bound before planning, otherwise it hides the extra probe row + // (or applies the offset twice). The backend still proves read safety. + const baseSql = mysqlTablePageBaseSql(sqlToExecute, pageLimit, pageOffset); + if (baseSql) { + const plan = await api.prepareQueryPaginationExecutionPlan({ + sql: baseSql, + queryBaseSql: sqlToExecute, + databaseType: effectiveDbType, + pagination, + useAgentCursor: false, + }); + if (plan.pageProbeSql) { + sqlToExecute = plan.pageProbeSql; + pageProbeLimit = pageLimit + 1; + } + } + } } errorLocateContext = { @@ -8592,7 +8619,7 @@ export const useQueryStore = defineStore("query", () => { pageSize: pageLimit, resultSessionId: options?.pagination?.sessionId, } - : { maxRows: pageLimit, fetchSize: pageLimit } + : { maxRows: pageProbeLimit ?? pageLimit, fetchSize: pageProbeLimit ?? pageLimit } : { maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows) }), ...(useJdbcDriverRowOffset && typeof pageOffset === "number" && pageOffset > 0 ? { rowOffset: pageOffset } : {}), ...(executionClientSessionId ? { clientSessionId: executionClientSessionId } : {}), @@ -8738,7 +8765,7 @@ export const useQueryStore = defineStore("query", () => { sqlToExecute, executionDatabase, executionSchema, - cursor ? agentProtocolQueryResultMaxRows(queryResultMaxRows) : (pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows)), + cursor ? agentProtocolQueryResultMaxRows(queryResultMaxRows) : (pageProbeLimit ?? pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows)), useLargeValuePreview, cursor ? pageLimit : undefined, cursor ? options?.pagination?.sessionId : undefined, @@ -8820,6 +8847,9 @@ export const useQueryStore = defineStore("query", () => { const resultProcessingStartedAt = performance.now(); const annotatedResults = annotateQueryResultSources(markQueryResultsRowsRaw(responseResults), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset, sqlStatementParameterOptions, sqlToExecute, options?.sourceOffset === undefined ? undefined : tab.sql); const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0); + if (pageProbeLimit !== undefined && pageLimit !== undefined && results.length === 1) { + results[0] = markQueryResultRowsRaw(trimQueryPageProbe(results[0]!, pageLimit)); + } if (paginationRowNumberColumn && results.length === 1) { results[0] = markQueryResultsRowsRaw([stripPaginationRowNumber(results[0]!, paginationRowNumberColumn)])[0]!; } @@ -8982,13 +9012,21 @@ export const useQueryStore = defineStore("query", () => { const resultLimitReached = !!current.result && queryResultLimitReached(resultStartOffset, resultRowCount, queryResultMaxRows); if (resultLimitReached && current.result) { current.result.has_more = false; - current.result.truncated = true; + current.result.truncated = current.result.page_has_more !== false; current.resultTotalRowCount = queryResultMaxRows; } const paginationPageResult = shouldAppendResult ? results[0] : current.result; const exactIncompletePageTotal = paginationPageResult ? exactTotalFromIncompletePage(paginationPageResult, pageLimit, pageOffset, useAgentResultSession) : undefined; const totalKnownFromIncompletePage = typeof exactIncompletePageTotal === "number"; let totalRowCountResolved = false; + if (paginationPageResult?.page_has_more === false && typeof exactIncompletePageTotal === "number") { + current.resultTotalRowCount = exactIncompletePageTotal; + current.resultTotalRowCountLoading = false; + totalRowCountResolved = true; + } else if (paginationPageResult?.page_has_more === true && typeof current.resultTotalRowCount === "number" && current.resultTotalRowCount <= (pageOffset ?? 0) + paginationPageResult.rows.length && !resultLimitReached) { + // A previous count belongs to an older snapshot; the probe proves it stale. + current.resultTotalRowCount = undefined; + } if (current.mode === "query" && current.result && !isQueryExecutionErrorResult(current.result) && typeof exactQueryRowBound === "number") { const boundedTotal = capQueryResultTotal(exactQueryRowBound, queryResultMaxRows); current.resultTotalRowCount = Math.min(boundedTotal, exactIncompletePageTotal ?? boundedTotal); @@ -10176,12 +10214,13 @@ export const useQueryStore = defineStore("query", () => { // Appended segments retain resultPageOffset=0, so inspect their last page. const sourceSnapshotComplete = sourceResult.has_more === false && + sourceResult.page_has_more !== true && !sourceResult.truncated && !sourceResult.large_value_cells?.length && !sourceResult.execution_error && sourcePageOffset === 0 && (typeof sourceTotalRows !== "number" || sourceTotalRows <= sourceResult.rows.length) && - (!tab.resultPageSql || hasShortDataGridSqlPage({ rowCount: sourceResult.rows.length, pageOffset: sourcePageOffset, executedPageOffset: sourceLastPageOffset, executedPageLimit: sourcePageLimit })); + (!tab.resultPageSql || sourceResult.page_has_more === false || hasShortDataGridSqlPage({ rowCount: sourceResult.rows.length, pageOffset: sourcePageOffset, executedPageOffset: sourceLastPageOffset, executedPageLimit: sourcePageLimit })); const sourceHiddenNames = new Set((sourceResult.hidden_column_indexes ?? []).map((index) => sourceResult.columns[index]).filter((name): name is string => name !== undefined && sourceResult.columns.filter((column) => column === name).length === 1)); const resultForTransfer = (result: QueryResult): QueryResult => { if (!probeRowLimit) return result; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 2c710a0323..3ab9d793f6 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1512,6 +1512,8 @@ export interface QueryResult { large_value_refs?: string[]; session_id?: string | null; has_more?: boolean; + /** Frontend-only SQL page lookahead, independent of the driver's cursor has_more. */ + page_has_more?: boolean; /** For Elasticsearch REST search results parsed into a _source table, * this carries the raw HTTP response body so the UI can toggle between * the tabular view and the original JSON. */ diff --git a/crates/dbx-sql-data/src/query_result_sql.rs b/crates/dbx-sql-data/src/query_result_sql.rs index f256cdf986..b070f7b4f4 100644 --- a/crates/dbx-sql-data/src/query_result_sql.rs +++ b/crates/dbx-sql-data/src/query_result_sql.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::ops::ControlFlow; +use dbx_sql_core::sql_risk::{prove_read_only_for_database, ReadProof}; use serde::{Deserialize, Serialize}; use crate::models::connection::DatabaseType; @@ -59,6 +60,10 @@ pub struct QueryPaginationExecutionPlanOptions { #[serde(rename_all = "camelCase")] pub struct QueryPaginationExecutionPlan { pub sql_to_execute: String, + /// Optional native MySQL page with one extra row. Only offered for queries + /// accepted by the strict read-only proof; callers must trim the probe row. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_probe_sql: Option, #[serde(skip_serializing_if = "Option::is_none")] pub page_sql: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -161,6 +166,7 @@ pub fn build_query_pagination_execution_plan( }; let mut plan = QueryPaginationExecutionPlan { sql_to_execute: options.sql.clone(), + page_probe_sql: None, page_sql: None, page_limit: None, page_offset: None, @@ -252,6 +258,34 @@ pub fn build_query_pagination_execution_plan( plan.page_sql = page_sql; plan.page_limit = Some(options.pagination.limit); plan.page_offset = Some(options.pagination.offset); + // A SELECT can mutate session state or invoke a user function. Do not + // evaluate an extra row unless both the original and rewritten query + // satisfy the same strict proof used for manual-transaction reads. + if options.database_type == Some(DatabaseType::Mysql) + && !options.use_agent_cursor + && options.pagination.limit > 0 + && prove_read_only_for_database(&options.sql, DatabaseType::Mysql) == ReadProof::ProvenReadOnly + && (options.sql == options.query_base_sql + || prove_read_only_for_database(&options.query_base_sql, DatabaseType::Mysql) + == ReadProof::ProvenReadOnly) + { + if let Some(probe_limit) = + options.pagination.limit.checked_add(1).filter(|limit| *limit <= i32::MAX as usize) + { + let probe = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: options.sql.clone(), + database_type: options.database_type, + limit: probe_limit, + offset: options.pagination.offset, + }); + if probe.ok { + plan.page_probe_sql = probe.sql.map(|sql| match execution_hint { + Some(hint) => inject_execution_hint(&sql, hint), + None => sql, + }); + } + } + } if options.pagination.offset > 0 && pagination_strategy(options.database_type, PaginationContext::UserQuery) == TablePaginationStrategy::Rownum diff --git a/packages/app-tests/useDataGridActions.test.ts b/packages/app-tests/useDataGridActions.test.ts index 7ffac7e20c..a299586218 100644 --- a/packages/app-tests/useDataGridActions.test.ts +++ b/packages/app-tests/useDataGridActions.test.ts @@ -78,6 +78,16 @@ test("data reload executes before slow metadata refresh completes", async () => if (url.pathname === "/api/query/build-table-select-sql") { return Response.json("SELECT * FROM `users` LIMIT 50 OFFSET 0"); } + if (url.pathname === "/api/query/prepare-pagination-plan") { + const { options } = JSON.parse(String(init?.body ?? "{}")); + assert.equal(options.sql, "SELECT * FROM `users`"); + assert.deepEqual(options.pagination, { limit: 50, offset: 0 }); + return Response.json({ + sqlToExecute: "SELECT * FROM `users` LIMIT 50;", + pageProbeSql: "SELECT * FROM `users` LIMIT 51;", + useAgentResultSession: false, + }); + } if (url.pathname === "/api/query/execute-multi") { executeBody = JSON.parse(String(init?.body ?? "{}")); return Response.json([{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]); @@ -100,6 +110,8 @@ test("data reload executes before slow metadata refresh completes", async () => await waitFor(() => !!executeBody, 5_000); assert.equal(executeBody.clientSessionId, tabId); + assert.equal(executeBody.sql, "SELECT * FROM `users` LIMIT 51;"); + assert.equal(executeBody.maxRows, 51); assert.deepEqual(tab.result?.rows, [[1]]); await waitFor(() => typeof resolveColumns === "function");