feat(query): add batch result actions

This commit is contained in:
二丫讲梵
2026-10-01 15:50:54 +08:00
committed by GitHub
parent e6c7baa2d9
commit 38a00c7cb7
18 changed files with 668 additions and 18 deletions
@@ -8109,6 +8109,7 @@ const {
exportCurrentPageXlsxWithSql,
exportAllResultsXlsx,
exportAllResultsXlsxWithSql,
exportResultSheetsXlsx,
exportSql,
exportCurrentPageSql,
exportTxt,
@@ -12145,6 +12146,7 @@ defineExpose({
exportJson,
exportSql,
exportXlsx,
exportResultSheetsXlsx,
openXlsx,
exportTxt,
defaultCopyPreference: selectedCopyPreference,
@@ -7,6 +7,7 @@ import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { queryResultMessages } from "@/lib/query/queryResultMessages";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput";
import { batchResultInsertRequest } from "@/lib/query/queryResultBatchInsert";
import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
@@ -190,7 +191,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryMessage, QueryTab, RedisResultViewMode, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { QueryMessage, QueryResult, QueryTab, RedisResultViewMode, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -221,6 +222,7 @@ type DataGridHandle = DataGridColumnLayoutHandle & {
exportJson: () => Promise<void>;
exportSql: () => Promise<void>;
exportXlsx: () => Promise<void>;
exportResultSheetsXlsx: (sheets: Array<{ sheetName: string; result: QueryResult; sql?: string }>) => Promise<void>;
openXlsx: () => Promise<void>;
};
@@ -515,6 +517,82 @@ const allResultExportSheets = computed(() =>
sql: item.index === props.activeTab.activeResultIndex ? queryResultExecutionSql(props.activeTab) : item.result.sourceStatement,
})),
);
type ResultSetItem = (typeof visibleResultItems.value)[number];
const resultBatchBusy = ref(false);
async function copySelectedResultSql(items: ResultSetItem[]) {
if (resultBatchBusy.value) return;
resultBatchBusy.value = true;
const tab = props.activeTab;
const generation = tab.resultViewGeneration;
const runId = tab.activeResultRunId;
const databaseType = activeEffectiveDatabaseType.value;
const identifierQuote = connectionStore.connectionIdentifierQuote(activeResultConnectionId.value);
const extractorOptions = JSON.parse(JSON.stringify(settingsStore.editorSettings.dataGridExtractorOptions));
extractorOptions.sql.includeDatabaseName = settingsStore.editorSettings.generateSqlIncludeDatabaseName;
const isCurrent = () => props.activeTab === tab && tab.activeResultRunId === runId && tab.resultViewGeneration === generation && items.every((item) => (tab.results ?? [tab.result]).includes(item.result));
try {
const sections: string[] = [];
let bytes = 0;
for (const item of items) {
const metadata = await queryStore.resolveResultMetadataForBatch(tab.id, item.result);
const request = batchResultInsertRequest(item.result, metadata, databaseType, identifierQuote, extractorOptions);
if (!request) throw new Error(t("tabs.batchCopyUnsupportedResult", { name: item.label || t("tabs.resultN", { n: item.n }) }));
if (!request.rows.length) continue;
const extraction = await api.extractDataGridSelection(request);
if (!extraction.text.trim()) throw new Error(t("tabs.batchCopyUnsupportedResult", { name: item.label || t("tabs.resultN", { n: item.n }) }));
const label = (item.label || t("tabs.resultN", { n: item.n })).replace(/[\r\n]/g, " ");
const section = `-- ${label}\n${extraction.text.trim()}`;
bytes += new TextEncoder().encode(section).length + 2;
if (bytes > 32 * 1024 * 1024) throw new Error(t("tabs.batchCopyTooLarge"));
sections.push(section);
}
if (!isCurrent()) throw new Error(t("tabs.batchResultsChanged"));
if (!sections.length) throw new Error(t("tabs.batchCopyNoCompatibleResults"));
await copyToClipboard(sections.join("\n\n"));
toast(t("grid.copied"));
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
} finally {
resultBatchBusy.value = false;
}
}
async function exportSelectedResultSheets(items: ResultSetItem[]) {
if (resultBatchBusy.value) return;
const dataGrid = dataGridRef.value;
if (!dataGrid) return;
const sheets = items.filter((item) => !item.result.execution_error && !item.result.server_message).map((item) => ({ sheetName: item.label || t("tabs.resultN", { n: item.n }), result: item.result }));
if (!sheets.length) return;
resultBatchBusy.value = true;
try {
await dataGrid.exportResultSheetsXlsx(sheets);
} finally {
resultBatchBusy.value = false;
}
}
async function copySelectedResultQueries(items: ResultSetItem[]) {
const sections = items
.map((item) => {
const sql = item.result.sourceStatement?.trim();
const label = (item.label || t("tabs.resultN", { n: item.n })).replace(/[\r\n]/g, " ");
return sql ? `-- ${label}\n${sql.endsWith(";") ? sql : `${sql};`}` : "";
})
.filter(Boolean);
if (!sections.length) {
toast(t("tabs.batchCopyNoCompatibleResults"), 5000);
return;
}
try {
await copyToClipboard(sections.join("\n\n"));
toast(t("grid.copied"));
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
}
}
// 结果标签优先显示来源(表名),历史批次缺少来源信息时按批次 SQL 重新解析
const resultRuns = computed(() =>
resultRunItems(props.activeTab, {
@@ -1877,7 +1955,19 @@ defineExpose({
</div>
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
<ResultSetNavigator
v-if="visibleResultItems.length > 0"
:key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`"
:items="visibleResultItems"
:busy="resultBatchBusy"
:can-export-xlsx="activeOutputView === 'result' && redisResultViewMode === 'grid' && !!activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse"
:active-index="activeTab.activeResultIndex ?? 0"
:active="activeOutputView === 'result'"
@select="selectResultItem"
@copy-sql="copySelectedResultSql"
@copy-query-sql="copySelectedResultQueries"
@export-xlsx="exportSelectedResultSheets"
/>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && redisResultViewMode === 'grid' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Check, ChevronDown } from "@lucide/vue";
import { Check, ChevronDown, Copy, FileCode2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import LightTooltip from "@/components/ui/LightTooltip.vue";
@@ -9,23 +9,46 @@ import { useTabScroll } from "@/composables/useTabScroll";
import type { tabularResultItems } from "@/lib/tabs/tabPresentation";
type ResultItem = ReturnType<typeof tabularResultItems>[number];
const props = defineProps<{ items: ResultItem[]; activeIndex: number; active: boolean }>();
const emit = defineEmits<{ select: [item: ResultItem] }>();
const props = defineProps<{
items: ResultItem[];
activeIndex: number;
active: boolean;
busy?: boolean;
canExportXlsx?: boolean;
}>();
const emit = defineEmits<{
select: [item: ResultItem];
copySql: [items: ResultItem[]];
copyQuerySql: [items: ResultItem[]];
exportXlsx: [items: ResultItem[]];
}>();
const { t } = useI18n();
const scroller = ref<HTMLElement | null>(null);
const list = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
const batchSearchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const search = ref("");
const batchOpen = ref(false);
const batchSearch = ref("");
const selectedIndexes = ref<Set<number>>(new Set());
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(scroller);
const thumbStyle = computed(() => ({ left: `${scrollThumbLeftPercent.value}%`, width: `${scrollThumbWidthPercent.value}%` }));
const filteredItems = computed(() => {
const query = search.value.trim().toLocaleLowerCase();
if (!query) return props.items;
function filterResultItems(items: ResultItem[], rawQuery: string) {
const query = rawQuery.trim().toLocaleLowerCase();
if (!query) return items;
// A number is an exact result ordinal, not a substring of every SQL statement.
if (/^\d+$/.test(query)) return props.items.filter((item) => item.n === Number(query));
return props.items.filter((item) => [item.label, item.title, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
if (/^\d+$/.test(query)) return items.filter((item) => item.n === Number(query));
return items.filter((item) => [item.label, item.title, item.result.sourceName, item.result.sourceLabel, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
}
const filteredItems = computed(() => {
return filterResultItems(props.items, search.value);
});
const filteredBatchItems = computed(() => {
return filterResultItems(props.items, batchSearch.value);
});
const selectedBatchItems = computed(() => props.items.filter((item) => selectedIndexes.value.has(item.index)));
function revealActive() {
const container = scroller.value;
@@ -54,6 +77,49 @@ function select(item: ResultItem) {
open.value = false;
}
function resetBatchSelection() {
selectedIndexes.value = new Set(props.items.map((item) => item.index));
}
function onBatchOpenAutoFocus() {
batchSearch.value = "";
resetBatchSelection();
batchSearchInput.value?.focus();
}
function setVisibleBatchSelection(selected: boolean) {
const next = new Set(selectedIndexes.value);
for (const item of filteredBatchItems.value) {
if (selected) next.add(item.index);
else next.delete(item.index);
}
selectedIndexes.value = next;
}
function toggleBatchItem(item: ResultItem) {
const next = new Set(selectedIndexes.value);
if (next.has(item.index)) next.delete(item.index);
else next.add(item.index);
selectedIndexes.value = next;
}
function emitBatch(action: "copySql" | "copyQuerySql" | "exportXlsx") {
const items = selectedBatchItems.value;
if (!items.length) return;
if (action === "copySql") emit("copySql", items);
else if (action === "copyQuerySql") emit("copyQuerySql", items);
else emit("exportXlsx", items);
batchOpen.value = false;
}
watch(
() => props.items,
() => {
resetBatchSelection();
},
{ immediate: true },
);
function focusListItem(index: number) {
const buttons = list.value?.querySelectorAll<HTMLButtonElement>("button");
if (!buttons?.length) return;
@@ -135,6 +201,48 @@ function onListKeydown(event: KeyboardEvent, index: number) {
</div>
</PopoverContent>
</Popover>
<Popover v-if="items.length > 1" v-model:open="batchOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs" :title="t('tabs.batchResultActions')">
<Copy class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{{ t("tabs.batchResultActions") }}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-[32rem] max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-2 p-2" @open-auto-focus.prevent="onBatchOpenAutoFocus">
<div class="flex items-center justify-between gap-2 border-b pb-2">
<span class="shrink-0 text-xs font-semibold">{{ t("tabs.batchResultActions") }}</span>
<input ref="batchSearchInput" v-model="batchSearch" type="search" class="min-w-0 flex-1 rounded-md border bg-background px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring" :placeholder="t('tabs.searchResults')" :aria-label="t('tabs.searchResults')" />
<span class="shrink-0 text-[11px] text-muted-foreground">{{ t("tabs.batchSelectedCount", { count: selectedBatchItems.length, total: items.length }) }}</span>
</div>
<div class="flex min-h-0 max-h-56 flex-col gap-1 overflow-y-auto py-1">
<label v-for="item in filteredBatchItems" :key="item.index" class="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-accent">
<input type="checkbox" :checked="selectedIndexes.has(item.index)" @change="toggleBatchItem(item)" />
<span class="min-w-0 flex-1 truncate">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
<span class="shrink-0 text-[10px] text-muted-foreground tabular-nums"
>{{ item.result.rows.length }}<template v-if="item.result.has_more || item.result.truncated"> · {{ t("tabs.batchIncompleteResult") }}</template></span
>
</label>
<p v-if="!filteredBatchItems.length" role="status" class="p-4 text-center text-xs text-muted-foreground">{{ t("tabs.noMatchingResults") }}</p>
</div>
<p class="text-[11px] text-muted-foreground">{{ t("tabs.batchLoadedRowsOnly") }}</p>
<div class="flex flex-wrap items-center gap-1 border-t pt-2">
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="!filteredBatchItems.length" @click="setVisibleBatchSelection(true)">{{ t("tabs.selectAllResults") }}</Button>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="!filteredBatchItems.length" @click="setVisibleBatchSelection(false)">{{ t("tabs.clearResultSelection") }}</Button>
<span class="flex-1" />
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" :disabled="busy || selectedBatchItems.length === 0" @click="emitBatch('copyQuerySql')">
<FileCode2 class="h-3.5 w-3.5" />
{{ t("tabs.copyResultQueries") }}
</Button>
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" :disabled="!canExportXlsx || busy || selectedBatchItems.length === 0" @click="emitBatch('exportXlsx')">
{{ t("tabs.exportSelectedResultsXlsx") }}
</Button>
<Button size="sm" class="h-6 gap-1 px-2 text-xs" :disabled="busy || selectedBatchItems.length === 0" @click="emitBatch('copySql')">
<Copy class="h-3.5 w-3.5" />
{{ t("tabs.copyResultAsSql") }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</template>
@@ -12,17 +12,59 @@ afterEach(() => {
document.body.replaceChildren();
});
async function mountNavigator(inputResults?: QueryResult[]) {
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true) {
// Include a non-tabular result so ordinal and storage index are different.
const results = inputResults ?? ([{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[]);
const select = vi.fn();
const copySql = vi.fn();
const copyQuerySql = vi.fn();
const exportXlsx = vi.fn();
const container = document.createElement("div");
document.body.append(container);
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex: 1, active: true, onSelect: select });
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { tabs: { resultN: "Result {n}", resultSets: "Result sets", allResults: "All results ({count})", searchResults: "Search", noMatchingResults: "No matches" } } } }));
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex: 1, active: true, canExportXlsx, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
app.use(
createI18n({
legacy: false,
locale: "en",
messages: {
en: {
tabs: {
resultN: "Result {n}",
resultSets: "Result sets",
allResults: "All results ({count})",
searchResults: "Search",
noMatchingResults: "No matches",
batchResultActions: "Batch actions",
batchSelectedCount: "{count}/{total} selected",
selectAllResults: "Select all",
clearResultSelection: "Clear",
copyResultQueries: "Copy queries",
copyResultAsSql: "Copy as SQL",
exportSelectedResultsXlsx: "Export XLSX",
batchLoadedRowsOnly: "Uses loaded rows",
batchIncompleteResult: "Incomplete",
},
},
},
}),
);
app.mount(container);
await nextTick();
return { container, select };
return { container, select, copySql, copyQuerySql, exportXlsx };
}
async function openBatchMenu(container: HTMLElement) {
[...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Batch actions"))!.click();
await nextTick();
await nextTick();
const popup = [...document.querySelectorAll<HTMLElement>('[data-slot="popover-content"]')].find((candidate) => candidate.textContent?.includes("Batch actions"))!;
const input = popup.querySelector<HTMLInputElement>('input[type="search"]')!;
const filter = async (query: string) => {
input.value = query;
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
};
return { popup, input, filter };
}
describe("large result-set navigation", () => {
@@ -71,4 +113,80 @@ describe("large result-set navigation", () => {
expect(select).toHaveBeenCalledOnce();
expect(select.mock.calls[0]![0]).toMatchObject({ n: 8, index: 8 });
});
it("emits the selected result sets without changing the active result", async () => {
const { container, copySql } = await mountNavigator([
{ columns: ["id"], rows: [[1]], sourceName: "first", sourceStatement: "SELECT 1" },
{ columns: ["id"], rows: [[2]], sourceName: "second", sourceStatement: "SELECT 2" },
]);
const batchTrigger = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Batch actions"))!;
batchTrigger.click();
await nextTick();
const action = [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Copy as SQL"))!;
action.click();
await nextTick();
expect(copySql).toHaveBeenCalledOnce();
expect(copySql.mock.calls[0]?.[0]).toHaveLength(2);
});
it.each([
["Copy as SQL", "copySql"],
["Copy queries", "copyQuerySql"],
["Export XLSX", "exportXlsx"],
] as const)("%s only includes selected results matching the batch search", async (action, event) => {
const mounted = await mountNavigator([
{ columns: [], rows: [] },
{ columns: ["id"], rows: [[1]], sourceLabel: "apis", sourceStatement: "SELECT * FROM apis" },
{ columns: ["id"], rows: [[2]], sourceLabel: "menus", sourceStatement: "SELECT * FROM menus WHERE enabled = true" },
]);
const { popup, filter } = await openBatchMenu(mounted.container);
await filter("APIS");
expect(popup.querySelectorAll('input[type="checkbox"]')).toHaveLength(1);
expect(popup.textContent).toContain("2/2 selected");
[...popup.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Clear")!.click();
await nextTick();
expect(popup.textContent).toContain("1/2 selected");
await filter("enabled");
expect(popup.querySelectorAll("label")[0]?.textContent).toContain("menus");
[...popup.querySelectorAll("button")].find((button) => button.textContent?.includes(action))!.click();
expect(mounted[event]).toHaveBeenCalledOnce();
expect(mounted[event].mock.calls[0]?.[0].map((item: { index: number }) => item.index)).toEqual([2]);
expect(mounted.select).not.toHaveBeenCalled();
});
it("disables XLSX export when the result view is inactive", async () => {
const mounted = await mountNavigator(
[
{ columns: ["id"], rows: [[1]], sourceStatement: "SELECT 1" },
{ columns: ["id"], rows: [[2]], sourceStatement: "SELECT 2" },
],
false,
);
const { popup } = await openBatchMenu(mounted.container);
const exportButton = [...popup.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Export XLSX"));
expect(exportButton?.disabled).toBe(true);
});
it("selects and clears only visible results and restores hidden selections when the search is cleared", async () => {
const { container } = await mountNavigator();
const { popup, filter } = await openBatchMenu(container);
await filter("8");
expect(popup.querySelectorAll('input[type="checkbox"]')).toHaveLength(1);
const clear = [...popup.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Clear")!;
const selectAll = [...popup.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Select all")!;
clear.click();
await nextTick();
expect(popup.textContent).toContain("99/100 selected");
await filter("");
expect(popup.textContent).toContain("99/100 selected");
await filter("8");
selectAll.click();
await nextTick();
await filter("");
expect(popup.textContent).toContain("100/100 selected");
await filter("no matching result");
expect(popup.querySelector('[role="status"]')?.textContent).toBe("No matches");
expect([...popup.querySelectorAll("button")].filter((button) => button.textContent?.includes("Select all") || button.textContent?.includes("Clear")).every((button) => button.disabled)).toBe(true);
});
});
@@ -1259,13 +1259,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
await exportCurrentPageXlsxResult(true);
}
async function exportAllResultsXlsxResult(includeSqlSheet: boolean) {
async function exportResultSheetsXlsxResult(sheetsToExport: Array<{ sheetName: string; result: QueryResult; sql?: string }> | undefined, includeSqlSheet: boolean) {
const exportOptions = await showXlsxHeaderDialog();
if (exportOptions === null) return;
await runExclusiveExport(async () => {
try {
const sheets = (allExportResults?.value ?? []).filter((sheet) => sheet.result.columns.length > 0);
const sheets = (sheetsToExport ?? allExportResults?.value ?? []).filter((sheet) => sheet.result.columns.length > 0);
if (sheets.length === 0) return;
let outputPath = exportFileName("query-results", "xlsx", { allResults: true });
@@ -1300,11 +1300,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportAllResultsXlsx() {
await exportAllResultsXlsxResult(false);
await exportResultSheetsXlsxResult(undefined, false);
}
async function exportAllResultsXlsxWithSql() {
await exportAllResultsXlsxResult(true);
await exportResultSheetsXlsxResult(undefined, true);
}
/**
@@ -1806,6 +1806,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportCurrentPageXlsxWithSql,
exportAllResultsXlsx,
exportAllResultsXlsxWithSql,
exportResultSheetsXlsx: (sheets: Array<{ sheetName: string; result: QueryResult; sql?: string }>) => exportResultSheetsXlsxResult(sheets, false),
exportSql,
exportCurrentPageSql,
copySql,
+13
View File
@@ -1996,6 +1996,19 @@ export default {
resultSets: "Result sets",
allResults: "All results ({count})",
searchResults: "Search number, name or SQL",
exportSelectedResultsXlsx: "Export XLSX",
batchLoadedRowsOnly: "Uses loaded rows and original values; queries are not re-executed.",
batchIncompleteResult: "Incomplete",
batchCopyUnsupportedResult: "{name}: no safe single-table INSERT mapping, or full cell values are unavailable. Nothing was copied.",
batchResultsChanged: "Results changed during generation. Nothing was copied; select the results again.",
batchResultActions: "Batch actions",
batchSelectedCount: "{count}/{total} selected",
selectAllResults: "Select all",
clearResultSelection: "Clear",
copyResultQueries: "Copy queries",
copyResultAsSql: "Copy as SQL",
batchCopyNoCompatibleResults: "No selected result can be copied as SQL.",
batchCopyTooLarge: "The generated SQL is too large for the clipboard. Export it to a file instead.",
noMatchingResults: "No matching results",
removeRun: "Remove run {n}",
renameResultRun: "Rename result tab",
+13
View File
@@ -1960,6 +1960,19 @@ export default withEnglishFallback({
resultSets: "Conjuntos de resultados",
allResults: "Todos los resultados ({count})",
searchResults: "Buscar por número, nombre o SQL",
exportSelectedResultsXlsx: "Exportar XLSX",
batchLoadedRowsOnly: "Usa las filas cargadas y los valores originales; no se vuelven a ejecutar las consultas.",
batchIncompleteResult: "Incompleto",
batchCopyUnsupportedResult: "{name}: no hay una asignación segura de INSERT para una sola tabla o faltan valores completos de celdas. No se copió nada.",
batchResultsChanged: "Los resultados cambiaron durante la generación. No se copió nada; vuelve a seleccionar los resultados.",
batchResultActions: "Acciones por lotes",
batchSelectedCount: "{count}/{total} seleccionados",
selectAllResults: "Seleccionar todo",
clearResultSelection: "Borrar",
copyResultQueries: "Copiar consultas",
copyResultAsSql: "Copiar como SQL",
batchCopyNoCompatibleResults: "Ningún resultado seleccionado se puede copiar como SQL.",
batchCopyTooLarge: "El SQL generado es demasiado grande para el portapapeles. Expórtalo a un archivo.",
noMatchingResults: "No hay resultados coincidentes",
removeRun: "Eliminar ejecución {n}",
renameResultRun: "Renombrar pestaña de resultados",
+13
View File
@@ -1894,6 +1894,19 @@ export default withEnglishFallback({
resultSets: "Set di risultati",
allResults: "Tutti i risultati ({count})",
searchResults: "Cerca per numero, nome o SQL",
exportSelectedResultsXlsx: "Esporta XLSX",
batchLoadedRowsOnly: "Usa le righe caricate e i valori originali; le query non vengono rieseguite.",
batchIncompleteResult: "Incompleto",
batchCopyUnsupportedResult: "{name}: nessuna mappatura INSERT sicura per una singola tabella oppure mancano i valori completi delle celle. Non è stato copiato nulla.",
batchResultsChanged: "I risultati sono cambiati durante la generazione. Non è stato copiato nulla; seleziona nuovamente i risultati.",
batchResultActions: "Azioni batch",
batchSelectedCount: "{count}/{total} selezionati",
selectAllResults: "Seleziona tutto",
clearResultSelection: "Cancella",
copyResultQueries: "Copia query",
copyResultAsSql: "Copia come SQL",
batchCopyNoCompatibleResults: "Nessun risultato selezionato può essere copiato come SQL.",
batchCopyTooLarge: "L'SQL generato è troppo grande per gli appunti. Esportalo in un file.",
noMatchingResults: "Nessun risultato corrispondente",
removeRun: "Rimuovi esecuzione {n}",
renameResultRun: "Rinomina scheda dei risultati",
+13
View File
@@ -1913,6 +1913,19 @@ export default withEnglishFallback({
resultSets: "結果セット",
allResults: "すべての結果({count})",
searchResults: "番号、名前、SQL で検索",
exportSelectedResultsXlsx: "XLSX をエクスポート",
batchLoadedRowsOnly: "読み込まれた行と元の値のみを使用します。クエリは再実行されません。",
batchIncompleteResult: "不完全",
batchCopyUnsupportedResult: "{name}: 安全な単一テーブル INSERT マッピングを確認できないか、セルの完全な値が読み込まれていません。コピーされませんでした。",
batchResultsChanged: "生成中に結果が変更されました。コピーされませんでした。結果をもう一度選択してください。",
batchResultActions: "一括操作",
batchSelectedCount: "{count}/{total} 件を選択中",
selectAllResults: "すべて選択",
clearResultSelection: "クリア",
copyResultQueries: "クエリをコピー",
copyResultAsSql: "SQL としてコピー",
batchCopyNoCompatibleResults: "選択した結果を SQL としてコピーできません。",
batchCopyTooLarge: "生成された SQL がクリップボードの容量を超えています。ファイルにエクスポートしてください。",
noMatchingResults: "一致する結果はありません",
removeRun: "実行 {n} を削除",
renameResultRun: "結果タブの名前を変更",
+13
View File
@@ -1808,6 +1808,19 @@ export default withEnglishFallback({
resultSets: "결과 집합",
allResults: "전체 결과 ({count})",
searchResults: "번호, 이름 또는 SQL 검색",
exportSelectedResultsXlsx: "XLSX 내보내기",
batchLoadedRowsOnly: "로드된 행과 원본 값만 사용하며 쿼리를 다시 실행하지 않습니다.",
batchIncompleteResult: "불완전",
batchCopyUnsupportedResult: "{name}: 안전한 단일 테이블 INSERT 매핑을 확인할 수 없거나 셀 전체 값이 로드되지 않았습니다. 복사하지 않았습니다.",
batchResultsChanged: "생성 중 결과가 변경되었습니다. 복사하지 않았습니다. 결과를 다시 선택하세요.",
batchResultActions: "일괄 작업",
batchSelectedCount: "{count}/{total}개 선택",
selectAllResults: "모두 선택",
clearResultSelection: "지우기",
copyResultQueries: "쿼리 복사",
copyResultAsSql: "SQL로 복사",
batchCopyNoCompatibleResults: "선택한 결과를 SQL로 복사할 수 없습니다.",
batchCopyTooLarge: "생성된 SQL이 클립보드 용량을 초과했습니다. 대신 파일로 내보내세요.",
noMatchingResults: "일치하는 결과가 없습니다",
removeRun: "실행 {n} 제거",
renameResultRun: "결과 탭 이름 변경",
+13
View File
@@ -1896,6 +1896,19 @@ export default withEnglishFallback({
resultSets: "Conjuntos de resultados",
allResults: "Todos os resultados ({count})",
searchResults: "Buscar por número, nome ou SQL",
exportSelectedResultsXlsx: "Exportar XLSX",
batchLoadedRowsOnly: "Usa as linhas carregadas e os valores originais; as consultas não são executadas novamente.",
batchIncompleteResult: "Incompleto",
batchCopyUnsupportedResult: "{name}: não há um mapeamento seguro de INSERT para uma única tabela ou os valores completos das células não estão disponíveis. Nada foi copiado.",
batchResultsChanged: "Os resultados mudaram durante a geração. Nada foi copiado; selecione os resultados novamente.",
batchResultActions: "Ações em lote",
batchSelectedCount: "{count}/{total} selecionados",
selectAllResults: "Selecionar tudo",
clearResultSelection: "Limpar",
copyResultQueries: "Copiar consultas",
copyResultAsSql: "Copiar como SQL",
batchCopyNoCompatibleResults: "Nenhum resultado selecionado pode ser copiado como SQL.",
batchCopyTooLarge: "O SQL gerado é grande demais para a área de transferência. Exporte-o para um arquivo.",
noMatchingResults: "Nenhum resultado correspondente",
removeRun: "Remover execução {n}",
renameResultRun: "Renomear aba de resultados",
+13
View File
@@ -1879,6 +1879,19 @@ export default withEnglishFallback({
resultSets: "Наборы результатов",
allResults: "Все результаты ({count})",
searchResults: "Поиск по номеру, имени или SQL",
exportSelectedResultsXlsx: "Экспортировать XLSX",
batchLoadedRowsOnly: "Используются загруженные строки и исходные значения; запросы не выполняются повторно.",
batchIncompleteResult: "Неполный",
batchCopyUnsupportedResult: "{name}: не удалось подтвердить безопасное сопоставление INSERT для одной таблицы или загружены не все значения ячеек. Ничего не скопировано.",
batchResultsChanged: "Результаты изменились во время формирования. Ничего не скопировано; выберите результаты снова.",
batchResultActions: "Пакетные действия",
batchSelectedCount: "Выбрано: {count}/{total}",
selectAllResults: "Выбрать все",
clearResultSelection: "Очистить",
copyResultQueries: "Копировать запросы",
copyResultAsSql: "Копировать как SQL",
batchCopyNoCompatibleResults: "Ни один выбранный результат нельзя скопировать как SQL.",
batchCopyTooLarge: "Сформированный SQL слишком велик для буфера обмена. Экспортируйте его в файл.",
noMatchingResults: "Нет подходящих результатов",
removeRun: "Удалить запуск {n}",
renameResultRun: "Переименовать вкладку результата",
+13
View File
@@ -1910,6 +1910,19 @@ export default withEnglishFallback({
resultSets: "语句结果",
allResults: "全部结果({count})",
searchResults: "搜索编号、名称或 SQL",
exportSelectedResultsXlsx: "导出 XLSX",
batchLoadedRowsOnly: "仅使用已加载行及原始值,不重新执行查询。",
batchIncompleteResult: "未加载完整",
batchCopyUnsupportedResult: "{name}:无法确认安全的单表 INSERT 映射,或存在未加载的完整单元格值。本次未复制。",
batchResultsChanged: "生成期间结果已变更,本次未复制,请重新选择结果集。",
batchResultActions: "批量操作",
batchSelectedCount: "已选择 {count}/{total}",
selectAllResults: "全选",
clearResultSelection: "清空",
copyResultQueries: "复制查询 SQL",
copyResultAsSql: "复制为 SQL",
batchCopyNoCompatibleResults: "所选结果没有可转换为 SQL 的结果集。",
batchCopyTooLarge: "生成的 SQL 超过剪贴板容量,请改用文件导出。",
noMatchingResults: "没有匹配的结果",
removeRun: "删除执行 {n}",
renameResultRun: "重命名结果标签",
+13
View File
@@ -1896,6 +1896,19 @@ export default withEnglishFallback({
resultSets: "語句結果",
allResults: "全部結果({count})",
searchResults: "搜尋編號、名稱或 SQL",
exportSelectedResultsXlsx: "匯出 XLSX",
batchLoadedRowsOnly: "僅使用已載入的資料列與原始值,不會重新執行查詢。",
batchIncompleteResult: "不完整",
batchCopyUnsupportedResult: "{name}:無法確認安全的單表 INSERT 對應,或尚未載入完整的儲存格值。未複製任何內容。",
batchResultsChanged: "產生期間結果已變更。未複製任何內容;請重新選取結果。",
batchResultActions: "批次操作",
batchSelectedCount: "已選取 {count}/{total}",
selectAllResults: "全選",
clearResultSelection: "清除",
copyResultQueries: "複製查詢",
copyResultAsSql: "複製為 SQL",
batchCopyNoCompatibleResults: "沒有選取的結果可複製為 SQL。",
batchCopyTooLarge: "產生的 SQL 太大,無法放入剪貼簿。請改為匯出至檔案。",
noMatchingResults: "沒有符合的結果",
removeRun: "移除執行 {n}",
renameResultRun: "重新命名結果分頁",
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { batchResultInsertRequest } from "../queryResultBatchInsert";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { QueryMetadataPatch } from "@/stores/queryStore";
import type { QueryResult } from "@/types/database";
const options: DataGridExtractorOptions = structuredClone(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
function metadata(): QueryMetadataPatch {
return {
queryAnalysis: { schema: undefined, tableName: "menus", tableAlias: "mn", selectStar: true, columns: [], allowInsert: true, allowInsertDelete: false },
tableMeta: {
tableName: "menus",
primaryKeys: ["id"],
columns: [
{ name: "id", data_type: "int", is_nullable: false },
{ name: "name", data_type: "varchar(100)", is_nullable: false },
],
},
};
}
describe("batch result INSERT requests", () => {
it("derives SELECT * columns from table metadata", () => {
const result: QueryResult = {
columns: ["id", "name"],
rows: [[1, "root"]],
sourceStatement: "SELECT * FROM menus mn LIMIT 100",
};
const request = batchResultInsertRequest(result, metadata(), "mysql", "`", options);
expect(request?.columns.map((column) => column.sourceName)).toEqual(["id", "name"]);
expect(request?.rows).toEqual([[1, "root"]]);
});
it("allows INSERT generation when the table has no primary key metadata", () => {
const result: QueryResult = {
columns: ["id", "name"],
rows: [[1, "root"]],
sourceStatement: "SELECT * FROM menus mn LIMIT 100",
};
const noKeyMetadata = metadata();
noKeyMetadata.queryAnalysis = undefined;
noKeyMetadata.tableMeta!.primaryKeys = [];
expect(batchResultInsertRequest(result, noKeyMetadata, "mysql", "`", options)?.rows).toEqual([[1, "root"]]);
});
it("keeps an aliased projection mapped to its source column", () => {
const result: QueryResult = {
columns: ["id"],
rows: [["root"]],
sourceStatement: "SELECT name AS id FROM menus",
};
const aliasedMetadata = { ...metadata(), querySourceColumns: ["name"] };
expect(batchResultInsertRequest(result, aliasedMetadata, "mysql", "`", options)?.columns[0]?.sourceName).toBe("name");
});
it("rejects a joined result even when metadata has a mapping", () => {
const result: QueryResult = {
columns: ["id", "name"],
rows: [[1, "root"]],
sourceStatement: "SELECT m.id, u.name FROM menus m JOIN users u ON u.id = m.user_id",
};
const joinedMetadata = { ...metadata(), queryAnalysis: { ...metadata().queryAnalysis!, multiSource: true } };
expect(batchResultInsertRequest(result, joinedMetadata, "mysql", "`", options)).toBeUndefined();
});
});
@@ -0,0 +1,33 @@
import { analyzeSelectStructureForDisplay, resolveMetadataColumnName } from "@/lib/sql/sqlAnalysis";
import { canInsertTableRows } from "@/lib/table/tableEditing";
import type { QueryMetadataPatch } from "@/stores/queryStore";
import type { QueryResult, DatabaseType } from "@/types/database";
import type { DataGridExtractRequest, DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
/** A display source label is insufficient to authorize an INSERT target. */
export function batchResultInsertRequest(result: QueryResult, metadata: QueryMetadataPatch | undefined, databaseType: DatabaseType | undefined, identifierQuote: string | undefined, options: DataGridExtractorOptions): DataGridExtractRequest | undefined {
if (result.execution_error || result.server_message || !result.sourceStatement || !databaseType || result.large_value_cells?.length) return undefined;
const structure = analyzeSelectStructureForDisplay(result.sourceStatement);
if (!structure || structure.multiSource || structure.distinct || structure.groupByColumns !== undefined || structure.hasHavingClause || structure.hasWindowClause) return undefined;
if (!structure.selectStar && structure.columns.some((column) => !column.sourceName)) return undefined;
const analysis = metadata?.queryAnalysis;
const tableMeta = metadata?.tableMeta;
const tableColumnNames = tableMeta?.columns?.map((item) => item.name);
const mapping = metadata?.querySourceColumns ?? (structure.selectStar && tableColumnNames?.length ? result.columns.map((column) => resolveMetadataColumnName(databaseType, column, undefined, tableColumnNames)) : undefined);
if (!tableMeta || !mapping || analysis?.multiSource || analysis?.allowInsert === false || !canInsertTableRows(databaseType) || tableMeta.tableType?.toUpperCase().includes("VIEW")) return undefined;
const hidden = new Set(result.hidden_column_indexes ?? []);
const indexes = result.columns.map((_, index) => index).filter((index) => !hidden.has(index));
if (!indexes.length || indexes.some((index) => !mapping[index])) return undefined;
return {
version: 1,
extractor: "sql-inserts",
databaseType,
identifierQuote,
tableMeta,
columns: indexes.map((index, ordinal) => ({ displayName: result.columns[index], sourceName: mapping[index], sourceIndex: ordinal })),
selectedColumnIndexes: indexes.map((_, index) => index),
rows: result.rows.map((row) => indexes.map((index) => row[index])),
selectionKind: "rows",
options,
};
}
@@ -188,6 +188,60 @@ describe("queryStore table data refresh", () => {
expect(store.tabs.find((tab) => tab.id === archiveTabId)?.result).toBeUndefined();
});
it("does not build batch INSERT metadata while data-tab metadata is pending", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "users", "data", "public");
const tab = store.tabs.find((candidate) => candidate.id === tabId)!;
const result = {
columns: ["id"],
rows: [[1]],
sourceStatement: "SELECT * FROM public.users",
};
tab.tableMeta = {
database: "app",
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [],
primaryKeys: [],
};
tab.tableMetaPending = true;
tab.result = result;
tab.results = [result];
await expect(store.resolveResultMetadataForBatch(tabId, tab.result!)).resolves.toBeUndefined();
});
it("maps active aliased query results to their source columns", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "Query", "query");
const tab = store.tabs.find((candidate) => candidate.id === tabId)!;
const result = {
columns: ["id"],
rows: [["root"]],
sourceStatement: "SELECT name AS id FROM users",
};
tab.tableMeta = {
database: "app",
tableName: "users",
tableType: "TABLE",
columns: [
{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "name", data_type: "text", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: ["id"],
};
tab.result = result;
tab.results = [result];
const metadata = await store.resolveResultMetadataForBatch(tabId, tab.result!);
expect(metadata?.queryAnalysis?.selectStar).toBe(false);
expect(metadata?.querySourceColumns).toEqual(["name"]);
});
it("uses JDBC ResultSet offset pagination for Caché data tabs", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "cache-1",
+56
View File
@@ -8755,6 +8755,61 @@ export const useQueryStore = defineStore("query", () => {
}
}
async function resolveResultMetadataForBatch(id: string, result: QueryResult): Promise<QueryMetadataPatch | undefined> {
const tab = findExecutionTab(id);
if (!tab || !result.sourceStatement) return undefined;
// Resolve on a detached context: checking a non-active result must never
// replace the visible grid or apply its metadata to another result.
const location = queryResultExecutionLocation(tab);
const connection = useConnectionStore().getConfig(location.connectionId);
const databaseType = effectiveDatabaseTypeForConnection(connection);
if (result === tab.result) {
if (tab.mode === "data" && (tab.tableMetaPending || !tab.tableMeta?.columns.length)) return undefined;
const tableMeta = tab.mode === "data" ? tableMetaForDataTab(tab) : tab.tableMeta;
const structure = analyzeSelectStructureForDisplay(result.sourceStatement);
const sameTable = structure?.tableName && tableMeta?.tableName && structure.tableName.toLowerCase() === tableMeta.tableName.toLowerCase() && (!structure.schema || !tableMeta.schema || structure.schema.toLowerCase() === tableMeta.schema.toLowerCase());
if (!sameTable || !tableMeta?.tableName || !canInsertTableRows(databaseType) || tableMeta.tableType?.toUpperCase().includes("VIEW")) return undefined;
const metadataNames = tableMeta.columns.map((column) => column.name);
const querySourceColumns = structure.selectStar
? result.columns.map((column) => resolveMetadataColumnName(databaseType ?? "", column, undefined, metadataNames))
: structure.columns.length === result.columns.length
? structure.columns.map((column) => {
if (!column.sourceName || (column.sourceQualifier && !column.sourceKey)) return undefined;
return resolveMetadataColumnName(databaseType ?? "", column.sourceName, column.sourceNameQuoted, metadataNames);
})
: undefined;
if (!querySourceColumns || querySourceColumns.some((column) => !column)) return undefined;
return {
queryAnalysis: {
schema: tableMeta.schema,
tableName: tableMeta.tableName,
tableAlias: structure.tableAlias,
selectStar: structure.selectStar,
columns: structure.columns,
allowInsert: true,
allowInsertDelete: true,
},
querySourceColumns,
queryEditabilityReason: undefined,
tableMeta,
};
}
const statements = splitSqlStatementRanges(tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql, databaseType);
const resultIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index! : Math.max(0, tab.results?.indexOf(result) ?? 0);
for (const [index, statement] of statements.entries()) {
if (index >= resultIndex) break;
const statementResult = tab.results?.find((candidate, fallbackIndex) => (candidate.statement_index ?? fallbackIndex) === index);
if (statementResult?.execution_error) continue;
location.database = useDatabaseFromStatement(statement.sql, databaseType) ?? location.database;
}
// Batch actions can also be invoked from data/table tabs. Metadata
// resolution is read-only and uses the same query parser, so provide the
// query-mode context expected by buildQueryMetadataPatch without changing
// the live tab mode or its displayed result.
const context = { ...tab, ...location, mode: "query" as const, result };
return buildQueryMetadataPatch(context, result.sourceStatement, location.database, undefined, undefined, [], connection);
}
function setActiveResultIndex(id: string, index: number) {
const tab = findExecutionTab(id);
if (!tab?.results || index < 0 || index >= tab.results.length) return;
@@ -9573,6 +9628,7 @@ export const useQueryStore = defineStore("query", () => {
closeQueryResult,
clearQueryResults,
setActiveResultIndex,
resolveResultMetadataForBatch,
executeCurrentTab,
executeCurrentSql,
executeTabSql,