fix(mysql): disable next page via read only probe

This commit is contained in:
二丫讲梵
2026-10-17 13:23:39 +08:00
committed by GitHub
parent 25539c6e24
commit a10bd55a73
12 changed files with 228 additions and 18 deletions
+16 -8
View File
@@ -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(() => {
@@ -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();
});
});
+2
View File
@@ -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;
@@ -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;
@@ -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<T extends QueryResult>(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;
@@ -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();
}
@@ -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,
@@ -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();
+47 -8
View File
@@ -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;
+2
View File
@@ -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. */
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_sql: Option<String>,
#[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
+12
View File
@@ -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");