fix(query): paginate multi-statement query results

This commit is contained in:
二丫讲梵
2026-10-19 14:07:43 +08:00
committed by GitHub
parent f77ddbce35
commit 6c955135d9
17 changed files with 341 additions and 14 deletions
+14 -3
View File
@@ -9023,6 +9023,9 @@ function changeExportQueryTimeout() {
const userFacingSql = ref("");
let userFacingSqlGeneration = 0;
// Legacy snapshots may retain a display page without the actual execution text.
// Do not present the original, unpaginated statement as the executed SQL there.
const actualExecutionSql = computed(() => props.result.sqlPage?.executedSql || props.result.executedStatement || (!props.pageSql ? props.result.sourceStatement || props.sql || "" : ""));
function sqlWithDisplayDatabaseName(sql: string): string {
const database = props.tableMeta?.database ?? props.database;
@@ -9036,7 +9039,7 @@ function sqlWithDisplayDatabaseName(sql: string): string {
async function syncUserFacingSql() {
const generation = ++userFacingSqlGeneration;
const executionSql = (props.pageSql || props.sql)?.trim() ?? "";
const executionSql = (props.result.sqlPage?.sql || props.pageSql || props.sql)?.trim() ?? "";
const includeDatabaseName = settingsStore.editorSettings.generateSqlIncludeDatabaseName;
const shouldRebuildSql = executionSql.includes("__DBX_LARGE_VALUE_BYTES_") || includeDatabaseName;
if (props.context !== "table-data" || !shouldRebuildSql || !props.tableMeta?.tableName) {
@@ -9078,7 +9081,7 @@ async function syncUserFacingSql() {
}
watch(
() => [props.sql, props.pageSql, props.context, props.tableMeta, props.pageLimit, props.pageOffset, props.executedPageLimit, props.executedPageOffset, currentWhereInput(), effectiveOrderBy(), settingsStore.editorSettings.generateSqlIncludeDatabaseName],
() => [props.sql, props.pageSql, props.result.sqlPage, props.context, props.tableMeta, props.pageLimit, props.pageOffset, props.executedPageLimit, props.executedPageOffset, currentWhereInput(), effectiveOrderBy(), settingsStore.editorSettings.generateSqlIncludeDatabaseName],
() => void syncUserFacingSql(),
{ immediate: true },
);
@@ -12439,8 +12442,9 @@ async function copyUserFacingSqlWithoutComments() {
}
const statusSqlContextMenuItems = computed<ContextMenuItem[]>(() => [
{ label: t("grid.copyQuerySql"), icon: Copy, action: () => void copyUserFacingSql() },
{ label: t(props.result.sqlPage ? "grid.copyCurrentPageSql" : "grid.copyQuerySql"), icon: Copy, action: () => void copyUserFacingSql() },
{ label: t("grid.copyExecutableSql"), icon: Copy, action: () => void copyUserFacingSqlWithoutComments() },
...(actualExecutionSql.value ? [{ label: t("grid.copyActualExecutionSql"), icon: Copy, action: () => void copyText(actualExecutionSql.value) }] : []),
]);
type TableInfoTabItem = {
@@ -16418,11 +16422,18 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<Tooltip>
<TooltipTrigger as-child>
<span class="block min-w-0 max-w-full truncate opacity-60 cursor-pointer hover:opacity-100" data-result-sql @click="copyUserFacingSql" @contextmenu="onContextMenu">
<span v-if="result.sqlPage">{{ t("grid.currentPageSql") }}: </span>
{{ sqlOneLiner }}
</span>
</TooltipTrigger>
<TooltipContent side="top" class="max-w-md">
<div v-if="result.sqlPage" class="mb-1 font-medium">{{ t("grid.currentPageSql") }}</div>
<pre class="text-xs font-mono whitespace-pre-wrap">{{ userFacingSql }}</pre>
<p v-if="result.sqlPage?.probe" class="mt-2 text-xs">{{ t("grid.pageProbeDescription", { count: result.sqlPage.executedLimit }) }}</p>
<template v-if="actualExecutionSql && actualExecutionSql.trim() !== userFacingSql.trim()">
<div class="mt-3 mb-1 font-medium">{{ t("grid.actualExecutionSql") }}</div>
<pre class="max-h-48 overflow-auto text-xs font-mono whitespace-pre-wrap">{{ actualExecutionSql }}</pre>
</template>
</TooltipContent>
</Tooltip>
</CustomContextMenu>
+5
View File
@@ -3321,6 +3321,11 @@ export default {
noAlterSqlAvailable: "No ALTER SQL available for this column",
copyAlterSqlFailed: "Copy failed: {message}",
copySqlCondition: "Copy SQL Condition",
currentPageSql: "Current page SQL",
actualExecutionSql: "Actual execution SQL",
copyCurrentPageSql: "Copy current page SQL",
copyActualExecutionSql: "Copy actual execution SQL",
pageProbeDescription: "Displays up to {count} rows for this page; reads at most one extra row to check whether another page exists.",
copyQuerySql: "Copy SQL",
copyExecutableSql: "Copy SQL (without comments)",
transpose: "Transpose Row",
+5
View File
@@ -3205,6 +3205,11 @@ export default withEnglishFallback({
tableDataPreviewDismiss: "No volver a mostrar",
tableDataPreviewLoaded: "Se cargaron {count} celdas con texto completo de la página actual",
tableDataPreviewLoadFailed: "Error al cargar el texto completo: {message}",
currentPageSql: "SQL de la página actual",
actualExecutionSql: "SQL realmente ejecutado",
copyCurrentPageSql: "Copiar SQL de la página actual",
copyActualExecutionSql: "Copiar SQL realmente ejecutado",
pageProbeDescription: "Esta página muestra como máximo {count} filas; se lee como máximo 1 fila adicional para determinar si hay una página siguiente.",
},
exportProgress: {
streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.",
+5
View File
@@ -3078,6 +3078,11 @@ export default withEnglishFallback({
tableDataPreviewDismiss: "Non mostrare più",
tableDataPreviewLoaded: "Caricate {count} celle con testo completo nella pagina corrente",
tableDataPreviewLoadFailed: "Impossibile caricare il testo completo: {message}",
currentPageSql: "SQL della pagina corrente",
actualExecutionSql: "SQL effettivamente eseguito",
copyCurrentPageSql: "Copia SQL della pagina corrente",
copyActualExecutionSql: "Copia SQL effettivamente eseguito",
pageProbeDescription: "Questa pagina mostra al massimo {count} righe; viene letta al massimo 1 riga aggiuntiva per determinare se esiste una pagina successiva.",
},
exportProgress: {
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",
+5
View File
@@ -3102,6 +3102,11 @@ export default withEnglishFallback({
tableDataPreviewDismiss: "今後表示しない",
tableDataPreviewLoaded: "現在のページの完全なテキストセルを {count} 個読み込みました",
tableDataPreviewLoadFailed: "完全なテキストの読み込みに失敗しました:{message}",
currentPageSql: "現在のページの SQL",
actualExecutionSql: "実際に実行された SQL",
copyCurrentPageSql: "現在のページの SQL をコピー",
copyActualExecutionSql: "実際に実行された SQL をコピー",
pageProbeDescription: "このページには最大 {count} 行を表示し、次のページがあるかどうかを判断するために最大 1 行を追加で読み取ります。",
},
exportProgress: {
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
+5
View File
@@ -3027,6 +3027,11 @@ export default withEnglishFallback({
tableDataPreviewDismiss: "다시 표시하지 않음",
tableDataPreviewLoaded: "현재 페이지의 전체 텍스트 셀 {count}개를 불러왔습니다",
tableDataPreviewLoadFailed: "전체 텍스트를 불러오지 못했습니다: {message}",
currentPageSql: "현재 페이지 SQL",
actualExecutionSql: "실제 실행 SQL",
copyCurrentPageSql: "현재 페이지 SQL 복사",
copyActualExecutionSql: "실제 실행 SQL 복사",
pageProbeDescription: "이 페이지에는 최대 {count}행이 표시되며, 다음 페이지가 있는지 판단하기 위해 최대 1행을 추가로 읽습니다.",
},
exportProgress: {
streamingUnsupported: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.",
+5
View File
@@ -3078,6 +3078,11 @@ export default withEnglishFallback({
tableDataPreviewDismiss: "Não mostrar novamente",
tableDataPreviewLoaded: "Carregadas {count} células de texto completo na página atual",
tableDataPreviewLoadFailed: "Falha ao carregar o texto completo: {message}",
currentPageSql: "SQL da página atual",
actualExecutionSql: "SQL de execução real",
copyCurrentPageSql: "Copiar SQL da página atual",
copyActualExecutionSql: "Copiar SQL de execução real",
pageProbeDescription: "Esta página exibe no máximo {count} linhas, com leitura adicional de no máximo 1 linha para determinar se há uma próxima página.",
},
exportProgress: {
streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.",
+5
View File
@@ -3162,6 +3162,11 @@ export default withEnglishFallback({
tableDataPreviewDismiss: "Больше не показывать",
tableDataPreviewLoaded: "Загружено {count} ячеек с полным текстом на текущей странице",
tableDataPreviewLoadFailed: "Не удалось загрузить полный текст: {message}",
currentPageSql: "SQL текущей страницы",
actualExecutionSql: "Фактически выполненный SQL",
copyCurrentPageSql: "Копировать SQL текущей страницы",
copyActualExecutionSql: "Копировать фактически выполненный SQL",
pageProbeDescription: "На этой странице отображается не более {count} строк; дополнительно считывается не более 1 строки, чтобы определить, есть ли следующая страница.",
},
exportProgress: {
streamingUnsupported: "Потоковый экспорт не поддерживается для этого запроса. Упростите его или используйте поддерживаемый драйвер.",
+5
View File
@@ -3385,6 +3385,11 @@ export default withEnglishFallback({
tableDataPreviewDismiss: "ไม่แสดงอีก",
tableDataPreviewLoaded: "โหลดเซลล์ข้อความฉบับเต็มในหน้าปัจจุบันแล้ว {count} เซลล์",
tableDataPreviewLoadFailed: "โหลดข้อความฉบับเต็มไม่สำเร็จ: {message}",
currentPageSql: "SQL ของหน้าปัจจุบัน",
actualExecutionSql: "SQL ที่ดำเนินการจริง",
copyCurrentPageSql: "คัดลอก SQL ของหน้าปัจจุบัน",
copyActualExecutionSql: "คัดลอก SQL ที่ดำเนินการจริง",
pageProbeDescription: "หน้านี้แสดงได้สูงสุด {count} แถว และอ่านเพิ่มได้สูงสุด 1 แถวเพื่อตรวจสอบว่ามีหน้าถัดไปหรือไม่",
},
exportProgress: {
streamingUnsupported: "ไม่รองรับการส่งออกแบบ Streaming สำหรับ Query นี้ ทำให้เรียบง่ายขึ้นหรือใช้ Driver ที่รองรับ",
+5
View File
@@ -3254,6 +3254,11 @@ export default withEnglishFallback({
noAlterSqlAvailable: "该列无 ALTER SQL 可复制",
copyAlterSqlFailed: "复制失败:{message}",
copySqlCondition: "复制 SQL 条件",
currentPageSql: "当前页 SQL",
actualExecutionSql: "实际执行 SQL",
copyCurrentPageSql: "复制当前页 SQL",
copyActualExecutionSql: "复制实际执行 SQL",
pageProbeDescription: "本页最多显示 {count} 行,额外读取最多 1 行以判断是否还有下一页。",
copyQuerySql: "复制 SQL",
copyExecutableSql: "复制 SQL(不含注释)",
transpose: "转置查看",
+5
View File
@@ -2860,6 +2860,11 @@ export default withEnglishFallback({
noAlterSqlAvailable: "此欄沒有可複製的 ALTER SQL",
copyAlterSqlFailed: "複製失敗:{message}",
copySqlCondition: "複製 SQL 條件",
currentPageSql: "目前頁面 SQL",
actualExecutionSql: "實際執行 SQL",
copyCurrentPageSql: "複製目前頁面 SQL",
copyActualExecutionSql: "複製實際執行 SQL",
pageProbeDescription: "本頁最多顯示 {count} 列,額外讀取最多 1 列以判斷是否還有下一頁。",
copyQuerySql: "複製 SQL",
copyExecutableSql: "複製 SQL(不含註解)",
transpose: "轉置檢視",
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { prepareBatchQueryPagination } from "@/lib/query/batchQueryPagination";
import type { QueryPaginationExecutionPlan, QueryPaginationExecutionPlanOptions } from "@/lib/backend/tauri";
function plan(overrides: Partial<QueryPaginationExecutionPlan> = {}): QueryPaginationExecutionPlan {
return {
pageSql: "SELECT id FROM t LIMIT 20 OFFSET 0",
sqlToExecute: "SELECT id FROM t LIMIT 20 OFFSET 0",
pageProbeSql: "SELECT id FROM t LIMIT 21 OFFSET 0",
pageLimit: 20,
pageOffset: 0,
useAgentResultSession: false,
...overrides,
} as QueryPaginationExecutionPlan;
}
function options(overrides: Partial<QueryPaginationExecutionPlanOptions> = {}): QueryPaginationExecutionPlanOptions {
return {
sql: "SELECT a FROM t1; SELECT b FROM t2",
queryBaseSql: "SELECT a FROM t1; SELECT b FROM t2",
databaseType: "postgres",
pagination: { limit: 20, offset: 0 },
useAgentCursor: false,
...overrides,
} as QueryPaginationExecutionPlanOptions;
}
describe("prepareBatchQueryPagination", () => {
it("keeps comments and preamble text between statements when rebuilding the batch", async () => {
const sql = "-- leading comment\nSELECT a FROM t1;\n/* between */ SELECT b FROM t2;\n-- trailing";
const prepare = vi.fn().mockResolvedValue(plan());
const result = await prepareBatchQueryPagination(options({ sql, queryBaseSql: sql }), prepare);
expect(result).toBeTruthy();
expect(prepare).toHaveBeenCalledTimes(2);
// Inter-statement text is preserved verbatim; only the SELECT bodies are replaced.
expect(result!.sql).toContain("-- leading comment\n");
expect(result!.sql).toContain("\n/* between */ ");
expect(result!.sql.endsWith("\n-- trailing")).toBe(true);
expect(result!.statements).toHaveLength(2);
});
it("falls back to no batch pagination when any statement fails to plan", async () => {
const prepare = vi.fn(async (request: QueryPaginationExecutionPlanOptions) => {
if (request.sql.includes("t2")) throw new Error("parser choked");
return plan();
});
const result = await prepareBatchQueryPagination(options(), prepare);
expect(result).toBeUndefined();
});
it("treats cancellation during planning as fatal instead of a fallback", async () => {
const prepare = vi.fn(async () => {
throw new Error("Query canceled");
});
await expect(prepareBatchQueryPagination(options(), prepare, undefined, () => true)).rejects.toThrow("Query canceled");
});
it("keeps statements whose planner returned the original SQL without a proven row bound", async () => {
const prepare = vi.fn(async (request: QueryPaginationExecutionPlanOptions) => {
if (request.sql.includes("t1")) return plan();
// An unchanged statement with no exactQueryRowBound is a driver row cap,
// not server pagination — the module must leave it byte-for-byte intact.
return plan({ pageSql: request.sql, sqlToExecute: request.sql });
});
const result = await prepareBatchQueryPagination(options(), prepare);
expect(result).toBeTruthy();
expect(result!.statements[1]).toBeUndefined();
expect(result!.sql).toContain("SELECT b FROM t2");
});
it("uses the probe SQL for mysql statements so page trimming stays possible", async () => {
const prepare = vi.fn().mockResolvedValue(plan());
const result = await prepareBatchQueryPagination(options({ databaseType: "mysql" }), prepare);
expect(result).toBeTruthy();
expect(result!.sql).toContain("LIMIT 21 OFFSET 0");
});
it("skips cursor sessions, sqlserver batches, and single statements", async () => {
const prepare = vi.fn();
expect(await prepareBatchQueryPagination(options({ useAgentCursor: true }), prepare)).toBeUndefined();
expect(await prepareBatchQueryPagination(options({ databaseType: "sqlserver" }), prepare)).toBeUndefined();
expect(await prepareBatchQueryPagination(options({ sql: "SELECT a FROM t1", queryBaseSql: "SELECT a FROM t1" }), prepare)).toBeUndefined();
expect(prepare).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,66 @@
import type { QueryPaginationExecutionPlan, QueryPaginationExecutionPlanOptions } from "@/lib/backend/tauri";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
export interface BatchQueryPaginationPlan {
sql: string;
statements: (QueryPaginationExecutionPlan | undefined)[];
}
/** Plan pages without splitting execution into separate connections or transactions. */
export async function prepareBatchQueryPagination(
options: QueryPaginationExecutionPlanOptions,
prepare: (options: QueryPaginationExecutionPlanOptions) => Promise<QueryPaginationExecutionPlan>,
parameterOptions?: SqlParameterOptions,
isCancelled?: () => boolean,
): Promise<BatchQueryPaginationPlan | undefined> {
// Cursor drivers and T-SQL batches have execution-unit semantics that cannot
// be replaced by independent SQL pages. Keep their existing execution path.
if (options.useAgentCursor || options.databaseType === "sqlserver" || options.pagination.sessionId) return;
const ranges = splitSqlStatementRanges(options.sql, options.databaseType, parameterOptions);
if (ranges.length < 2) return;
const statements: BatchQueryPaginationPlan["statements"] = [];
let planningFailed = false;
// Bound planning concurrency for large scripts; these calls only parse SQL.
for (let start = 0; start < ranges.length; start += 4) {
if (isCancelled?.()) throw new Error("Query canceled");
const plans = await Promise.all(
ranges.slice(start, start + 4).map(async (range) => {
try {
const plan = await prepare({ ...options, sql: range.sql, queryBaseSql: range.sql, firstPageUsesActualSql: false });
// Non-SELECT statements and unsupported rewrites stay byte-for-byte
// intact. Never turn a per-statement cursor into a batch cursor.
if (!plan.pageSql || plan.pageLimit === undefined || plan.pageOffset === undefined || plan.useAgentResultSession || plan.paginationError) return undefined;
// Some dialects return the original statement and rely on a driver row
// cap. That is not server pagination. An unchanged explicit LIMIT is
// safe only when the planner also proved its result bound.
if (plan.pageSql.trim().replace(/;$/, "").trimEnd() === range.sql.trim().replace(/;$/, "").trimEnd() && plan.exactQueryRowBound === undefined) return undefined;
return plan;
} catch (error) {
// Planning is an optimization for a batch. If one statement cannot
// be planned because of a parser/backend compatibility issue, let the
// caller execute the original batch so the other statements retain
// their normal multi-statement semantics. Cancellation remains fatal.
if (isCancelled?.()) throw error;
planningFailed = true;
return undefined;
}
}),
);
if (planningFailed) return;
statements.push(...plans);
}
if (!statements.some(Boolean)) return;
// Replace ranges, not delimiters/preambles: comments, client directives and
// custom routine delimiters must retain their positions relative to the SQL.
const chunks: string[] = [];
let end = 0;
ranges.forEach((range, index) => {
const plan = statements[index];
const executedSql = options.databaseType === "mysql" ? (plan?.pageProbeSql ?? plan?.sqlToExecute) : plan?.sqlToExecute;
chunks.push(options.sql.slice(end, range.from), executedSql ? executedSql.trimEnd().replace(/;$/, "") : range.sql);
end = range.to;
});
chunks.push(options.sql.slice(end));
return { sql: chunks.join(""), statements };
}
@@ -83,6 +83,8 @@ interface ColumnarQueryResult {
truncated?: boolean;
has_more?: boolean;
page_has_more?: boolean;
sqlPage?: QueryResult["sqlPage"];
executedStatement?: string;
sourceLabel?: string;
sourceLabelKind?: QueryResult["sourceLabelKind"];
sourceQualifier?: string;
@@ -382,6 +384,8 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
session_id: undefined,
has_more: result.has_more,
page_has_more: result.page_has_more,
sqlPage: result.sqlPage ? { ...result.sqlPage } : undefined,
executedStatement: result.executedStatement,
sourceLabel: result.sourceLabel,
sourceLabelKind: result.sourceLabelKind,
sourceQualifier: result.sourceQualifier,
@@ -448,6 +452,8 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
truncated: result.truncated,
has_more: result.has_more,
page_has_more: result.page_has_more,
sqlPage: result.sqlPage ? { ...result.sqlPage } : undefined,
executedStatement: result.executedStatement,
sourceLabel: result.sourceLabel,
sourceLabelKind: result.sourceLabelKind,
sourceQualifier: result.sourceQualifier,
@@ -488,6 +494,8 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
session_id: undefined,
has_more: result.has_more,
page_has_more: result.page_has_more,
sqlPage: result.sqlPage ? { ...result.sqlPage } : undefined,
executedStatement: result.executedStatement,
sourceLabel: result.sourceLabel,
sourceLabelKind: result.sourceLabelKind,
sourceQualifier: result.sourceQualifier,
+98 -7
View File
@@ -101,6 +101,7 @@ import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeo
import { queryResultNameFromPreamble, queryResultSourceLabel, queryResultSourceNameParts } from "@/lib/sql/queryResultSource";
import { sqlServerCountUsesLocalTempTable } from "@/lib/query/queryResultCountSession";
import { stripPaginationRowNumber, trimQueryPageProbe } from "@/lib/query/queryPaginationResult";
import { prepareBatchQueryPagination, type BatchQueryPaginationPlan } from "@/lib/query/batchQueryPagination";
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";
@@ -427,6 +428,8 @@ export function appendQueryResultSegment(previous: QueryResult, segment: QueryRe
// remains valid, while bounding the in-memory result by the configured cap.
return markQueryResultRowsRaw({
...segment,
statement_index: previous.statement_index ?? segment.statement_index,
sqlPage: segment.sqlPage ? { ...segment.sqlPage, limit: previous.sqlPage?.limit ?? segment.sqlPage.limit, offset: previous.sqlPage?.offset ?? segment.sqlPage.offset } : undefined,
appended_from_row_count: previous.rows.length,
rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)],
neo4j_node_cells: appendNeo4jNodeCells(previous, segment, appendedRowCount),
@@ -7752,6 +7755,7 @@ export const useQueryStore = defineStore("query", () => {
let requestedPageLimit: number | undefined;
let pageLimit: number | undefined;
let pageProbeLimit: number | undefined;
let batchPagination: BatchQueryPaginationPlan | undefined;
let pageOffset: number | undefined;
let countSql: string | undefined;
let exactQueryRowBound: number | undefined;
@@ -8564,7 +8568,7 @@ export const useQueryStore = defineStore("query", () => {
// 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);
pageSql = sqlServerUseScript && plan.pageSql ? replaceSqlServerLeadingUseQuery(sqlBeforePagination, sqlServerUseScript, plan.pageSql) : plan.pageSql;
pageProbeLimit = probeSql && typeof plan.pageLimit === "number" ? plan.pageLimit + 1 : undefined;
pageLimit = plan.pageLimit;
pageOffset = plan.pageOffset;
@@ -8573,7 +8577,23 @@ export const useQueryStore = defineStore("query", () => {
useAgentResultSession = plan.useAgentResultSession;
paginationRowNumberColumn = plan.paginationRowNumberColumn;
}
const hasBoundedPagination = typeof pageLimit === "number" && typeof pageOffset === "number";
if (!plan.pageSql && !plan.useAgentResultSession && !sqlServerUseScript) {
batchPagination = await prepareBatchQueryPagination(
{ sql: sqlBeforePagination, queryBaseSql, databaseType: effectiveDbType, pagination, useAgentCursor, executionHint: effectiveDbType === "gaussdb" ? gaussdbCountQueryDopHint(conn) : undefined },
api.prepareQueryPaginationExecutionPlan,
sqlStatementParameterOptions,
() => tab.executionId !== executionId || tab.isCancelling === true,
);
if (batchPagination) {
sqlToExecute = batchPagination.sql;
// The batch SQL already contains each statement's LIMIT/OFFSET;
// expose one logical page bound to the common execution options so
// append/load-all paths do not mistake this for an unbounded run.
pageLimit = pagination.limit;
pageOffset = pagination.offset;
}
}
const hasBoundedPagination = batchPagination || (typeof pageLimit === "number" && typeof pageOffset === "number");
if (options?.appendResult && !hasBoundedPagination && !useAgentResultSession) {
const current = findExecutionTab(id);
if (current?.executionId === executionId && current.result) {
@@ -8613,6 +8633,7 @@ export const useQueryStore = defineStore("query", () => {
});
if (plan.pageProbeSql) {
sqlToExecute = plan.pageProbeSql;
pageSql = plan.pageSql;
pageProbeLimit = pageLimit + 1;
}
}
@@ -8661,7 +8682,14 @@ export const useQueryStore = defineStore("query", () => {
pageSize: pageLimit,
resultSessionId: options?.pagination?.sessionId,
}
: { maxRows: pageProbeLimit ?? pageLimit, fetchSize: pageProbeLimit ?? pageLimit }
: {
// Each supported batch statement is bounded in its SQL. Keep
// the driver cap at the normal global limit so an unsupported
// statement in the same batch is not accidentally truncated
// to the probe size of another statement.
maxRows: batchPagination ? agentProtocolQueryResultMaxRows(queryResultMaxRows) : (pageProbeLimit ?? pageLimit),
fetchSize: pageProbeLimit ?? pageLimit,
}
: { maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows) }),
...(useJdbcDriverRowOffset && typeof pageOffset === "number" && pageOffset > 0 ? { rowOffset: pageOffset } : {}),
...(executionClientSessionId ? { clientSessionId: executionClientSessionId } : {}),
@@ -8807,7 +8835,7 @@ export const useQueryStore = defineStore("query", () => {
sqlToExecute,
executionDatabase,
executionSchema,
cursor ? agentProtocolQueryResultMaxRows(queryResultMaxRows) : (pageProbeLimit ?? pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows)),
cursor ? agentProtocolQueryResultMaxRows(queryResultMaxRows) : batchPagination ? agentProtocolQueryResultMaxRows(queryResultMaxRows) : (pageProbeLimit ?? pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows)),
useLargeValuePreview,
cursor ? pageLimit : undefined,
cursor ? options?.pagination?.sessionId : undefined,
@@ -8888,13 +8916,54 @@ 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);
// Attach page state before applying recovery's statement-index offset.
// Multiple result sets from an unplanned statement must not shift the
// next query's plan; prefer the backend's explicit statement index.
if (batchPagination) {
const statementPlans = batchPagination.statements;
annotatedResults.results = annotatedResults.results.map((result, index) => {
const statementIndex = result.statement_index ?? (annotatedResults.results.length === statementPlans.length ? index : undefined);
const plan = statementIndex === undefined ? undefined : statementPlans[statementIndex];
if (!plan?.pageSql || plan.pageLimit === undefined || plan.pageOffset === undefined || result.execution_error || result.server_message || result.columns.length === 0) return result;
const probe = effectiveDbType === "mysql" && !!plan.pageProbeSql;
const pagedResult = stripPaginationRowNumber(probe ? trimQueryPageProbe(result, plan.pageLimit) : result, plan.paginationRowNumberColumn);
return markQueryResultRowsRaw({
...pagedResult,
statement_index: statementIndex,
sqlPage: {
sql: plan.pageSql,
executedSql: probe ? plan.pageProbeSql! : plan.sqlToExecute,
limit: requestedPageLimit ?? plan.pageLimit,
offset: plan.pageOffset,
executedLimit: plan.pageLimit,
executedOffset: plan.pageOffset,
probe,
countSql: plan.countSql,
exactQueryRowBound: plan.exactQueryRowBound,
},
});
});
}
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
if (pageProbeLimit !== undefined && pageLimit !== undefined && results.length === 1) {
if (!batchPagination && pageProbeLimit !== undefined && pageLimit !== undefined && results.length === 1) {
results[0] = markQueryResultRowsRaw(trimQueryPageProbe(results[0]!, pageLimit));
}
if (paginationRowNumberColumn && results.length === 1) {
if (!batchPagination && paginationRowNumberColumn && results.length === 1) {
results[0] = markQueryResultsRowsRaw([stripPaginationRowNumber(results[0]!, paginationRowNumberColumn)])[0]!;
}
if (!batchPagination && results.length === 1 && pageSql !== undefined && !useAgentResultSession && pageLimit !== undefined && pageOffset !== undefined && !results[0]!.execution_error) {
results[0]!.sqlPage = {
sql: pageSql,
executedSql: sqlToExecute,
limit: requestedPageLimit ?? pageLimit,
offset: pageOffset,
executedLimit: pageLimit,
executedOffset: pageOffset,
probe: pageProbeLimit !== undefined,
countSql,
exactQueryRowBound,
};
}
reconcileBatchSqlResults(tab, executionId, results);
// Sticky proven-read-only aggregation (Oracle/OceanBase-Oracle/MySQL/PG).
// Only the initial manual execution participates: a later cursor-page
@@ -9005,6 +9074,7 @@ export const useQueryStore = defineStore("query", () => {
}
current.result = appendedResult;
} else if (shouldReplaceActiveResultInGroup) {
results[0]!.statement_index = current.result?.statement_index ?? results[0]!.statement_index;
current.results = activeGroupResults.slice();
current.results[activeGroupIndex] = results[0];
current.result = results[0];
@@ -9027,6 +9097,14 @@ export const useQueryStore = defineStore("query", () => {
// the same dataset; every other branch above replaces it.
publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));
producedResult = current.result !== undefined;
if (batchPagination) {
const activePage = current.result?.sqlPage;
pageSql = activePage?.sql;
pageLimit = activePage?.executedLimit;
pageOffset = activePage?.executedOffset;
countSql = activePage?.countSql;
exactQueryRowBound = activePage?.exactQueryRowBound;
}
current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
@@ -9093,6 +9171,7 @@ export const useQueryStore = defineStore("query", () => {
})()
: undefined;
const canAutoCalculateTotalRows =
!batchPagination &&
!options?.appendResult &&
!!current.result &&
resultRowCount > 0 &&
@@ -9113,7 +9192,7 @@ export const useQueryStore = defineStore("query", () => {
}
touchResult(current);
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
if (!options?.appendResult && !resultLimitReached && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
if (!batchPagination && !options?.appendResult && !resultLimitReached && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
connectionId: executionConnectionId,
@@ -9853,8 +9932,20 @@ export const useQueryStore = defineStore("query", () => {
function setActiveResultIndex(id: string, index: number) {
const tab = findExecutionTab(id);
if (!tab?.results || index < 0 || index >= tab.results.length) return;
const previousPage = tab.result?.sqlPage;
tab.activeResultIndex = index;
tab.result = tab.results[index];
const page = tab.result?.sqlPage;
if (page || previousPage) {
tab.resultPageSql = page?.sql;
tab.resultPageLimit = page?.limit;
tab.resultPageOffset = page?.offset;
tab.resultExecutedPageLimit = page?.executedLimit;
tab.resultExecutedPageOffset = page?.executedOffset;
tab.resultCountSql = page?.countSql;
tab.resultSessionId = tab.result?.session_id ?? undefined;
if (!tab.resultSessionId) tab.resultClientSessionId = undefined;
}
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalLargeValueCells = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
+12
View File
@@ -1514,6 +1514,18 @@ export interface QueryResult {
has_more?: boolean;
/** Frontend-only SQL page lookahead, independent of the driver's cursor has_more. */
page_has_more?: boolean;
/** Frontend-only page state, owned by the result rather than the active tab. */
sqlPage?: {
sql: string;
executedSql: string;
limit: number;
offset: number;
executedLimit: number;
executedOffset: number;
probe: boolean;
countSql?: string;
exactQueryRowBound?: number;
};
/** 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. */
+8 -4
View File
@@ -9098,20 +9098,24 @@ test("query results keep readable table source labels with active database conte
});
}
if (url === "/api/query/execute-multi") {
// Multi-statement pagination plans each statement separately, so the
// prepare mock's shared currentSql may hold the last planned statement
// here. Match on the batch this request actually executes instead.
const requestSql: string = JSON.parse(String(init?.body ?? "{}")).sql ?? currentSql;
const results =
currentSql === "select * from users; select * from orders"
requestSql === "select * from users; select * from orders"
? [
{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
]
: currentSql === "SELECT *\nFROM apis AS ap\nLIMIT 10;\n\nSELECT *\nFROM menus AS mn\nLIMIT 10;"
: requestSql === "SELECT *\nFROM apis AS ap\nLIMIT 10;\n\nSELECT *\nFROM menus AS mn\nLIMIT 10;"
? [
{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
]
: currentSql === "select * from public.users"
: requestSql === "select * from public.users"
? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]
: currentSql === "select u.id from users u join orders o on o.user_id = u.id"
: requestSql === "select u.id from users u join orders o on o.user_id = u.id"
? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]
: [
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 },