mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(query): add batch result actions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "結果タブの名前を変更",
|
||||
|
||||
@@ -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: "결과 탭 이름 변경",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Переименовать вкладку результата",
|
||||
|
||||
@@ -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: "重命名结果标签",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user