mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:04:25 +00:00
fix(mysql): disable next page via read only probe
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user