mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 08:15:46 +00:00
fix(query): paginate multi-statement query results
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
|
||||
|
||||
@@ -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: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "Потоковый экспорт не поддерживается для этого запроса. Упростите его или используйте поддерживаемый драйвер.",
|
||||
|
||||
@@ -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 ที่รองรับ",
|
||||
|
||||
@@ -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: "转置查看",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user