feat(export): add SQL INSERT batch or single-row mode

This commit is contained in:
jearhe authored and GitHub committed 2026-09-06 10:27:34 +08:00
1 parent 5d65be03a3
commit ab35a5b1da
29 files changed
+563 -23

No files matched your search

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const selected = ref<SqlInsertMode>("batch");
let outcomeEmitted = false;
const emit = defineEmits<{
confirm: [mode: SqlInsertMode];
cancel: [];
}>();
function onConfirm() {
outcomeEmitted = true;
open.value = false;
emit("confirm", selected.value);
}
function onCancel() {
if (outcomeEmitted) return;
outcomeEmitted = true;
open.value = false;
emit("cancel");
}
function onOpenChange(value: boolean) {
if (!value) onCancel();
}
</script>
<template>
<Dialog v-model:open="open" @update:open="onOpenChange">
<DialogContent class="sm:max-w-md" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ t("grid.sqlInsertModeTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-3 py-2">
<p class="text-sm text-muted-foreground">{{ t("grid.sqlInsertModePrompt") }}</p>
<label class="flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors hover:bg-accent/50">
<input v-model="selected" type="radio" value="batch" class="mt-0.5 h-4 w-4 shrink-0" data-sql-insert-mode="batch" />
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t("grid.sqlInsertModeBatch") }}</span>
<span class="mt-1 block text-xs text-muted-foreground">{{ t("grid.sqlInsertModeBatchDescription") }}</span>
</span>
</label>
<label class="flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors hover:bg-accent/50">
<input v-model="selected" type="radio" value="single" class="mt-0.5 h-4 w-4 shrink-0" data-sql-insert-mode="single" />
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t("grid.sqlInsertModeSingle") }}</span>
<span class="mt-1 block text-xs text-muted-foreground">{{ t("grid.sqlInsertModeSingleDescription") }}</span>
</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" @click="onCancel">{{ t("common.cancel") }}</Button>
<Button data-sql-insert-mode-confirm @click="onConfirm">{{ t("common.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, type App, defineComponent } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h("div", attrs, slots.default?.());
},
});
return { Dialog: passthrough, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
import SqlInsertModeDialog from "@/components/export/SqlInsertModeDialog.vue";
const mountedApps: App[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
i18n.global.locale.value = "en";
});
async function mountDialog(onConfirm = () => {}, onCancel = () => {}) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(
defineComponent({
setup() {
return () => h(SqlInsertModeDialog, { open: true, onConfirm, onCancel });
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
}
describe("SqlInsertModeDialog", () => {
it("defaults to batch mode and emits the selected single-row mode", async () => {
const onConfirm = vi.fn();
await mountDialog(onConfirm);
const batch = document.querySelector<HTMLInputElement>('input[data-sql-insert-mode="batch"]');
const single = document.querySelector<HTMLInputElement>('input[data-sql-insert-mode="single"]');
expect(batch?.checked).toBe(true);
expect(single?.checked).toBe(false);
single?.click();
await nextTick();
document.querySelector<HTMLButtonElement>("[data-sql-insert-mode-confirm]")?.click();
expect(onConfirm).toHaveBeenCalledOnce();
expect(onConfirm).toHaveBeenCalledWith("single");
});
it("renders the batch and single-row explanations", async () => {
await mountDialog();
expect(document.body.textContent).toContain("Batch append");
expect(document.body.textContent).toContain("One row per statement");
expect(document.body.textContent).toContain("Combine multiple rows into each INSERT statement");
expect(document.body.textContent).toContain("Write one complete INSERT statement per row");
});
it("emits cancel without selecting a mode", async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
await mountDialog(onConfirm, onCancel);
const cancel = [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Cancel"));
cancel?.click();
expect(onCancel).toHaveBeenCalledOnce();
expect(onConfirm).not.toHaveBeenCalled();
});
});
+10 -1
View File
@@ -91,6 +91,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import * as api from "@/lib/backend/api";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
import { createColumnDrafts } from "@/lib/table/tableStructureEditorState";
import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructureEditorSql";
@@ -511,7 +512,15 @@ interface DataGridProps {
exportSql?: string;
onExecuteSql?: (sql: string) => Promise<void>;
fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise<QueryResult | undefined>;
queryResultExportRequest?: (options: { exportId: string; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array<string | null | undefined> }) => Promise<api.QueryResultExportRequest | undefined>;
queryResultExportRequest?: (options: {
exportId: string;
filePath: string;
format: "csv" | "xlsx" | "txt" | "sql";
includeSqlSheet?: boolean;
exportTableName?: string;
exportColumnTypes?: Array<string | null | undefined>;
insertMode?: SqlInsertMode;
}) => Promise<api.QueryResultExportRequest | undefined>;
allExportResults?: Array<{
sheetName: string;
result: QueryResult;
@@ -154,6 +154,7 @@ import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
import { elasticsearchProfileBodyForResult, parseElasticsearchProfile } from "@/lib/elasticsearch/elasticsearchProfile";
import * as api from "@/lib/backend/api";
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
@@ -1889,7 +1890,8 @@ defineExpose({
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
:query-result-export-request="
(options: { exportId: string; filePath: string; format: 'csv' | 'xlsx' | 'txt' | 'sql'; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array<string | null | undefined> }) => queryStore.buildQueryResultExportRequest(activeTab.id, options)
(options: { exportId: string; filePath: string; format: 'csv' | 'xlsx' | 'txt' | 'sql'; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array<string | null | undefined>; insertMode?: SqlInsertMode }) =>
queryStore.buildQueryResultExportRequest(activeTab.id, options)
"
:all-export-results="allResultExportSheets"
:export-file-base-name="activeTab.title"
@@ -92,6 +92,7 @@ import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRe
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { showSqlInsertModeDialog, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
import {
defaultPasteTableMode,
@@ -2097,8 +2098,13 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json") {
}
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
if (format === "json") await exportDataLegacy(row, format);
else await exportTableData(row, format);
if (format === "json") {
await exportDataLegacy(row, format);
return;
}
const insertMode = format === "sql" ? await showSqlInsertModeDialog() : undefined;
if (format === "sql" && insertMode === null) return;
await exportTableData(row, format, undefined, "name", true, insertMode ?? "batch");
}
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<XlsxExportOptions | null> {
@@ -2141,7 +2147,7 @@ async function exportDataXlsx(row: ObjectBrowserRow) {
await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter);
}
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true) {
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode: SqlInsertMode = "batch") {
const schema = row.schema || selectedSchema.value;
// Save dialog first
@@ -2215,6 +2221,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
tableName: row.name,
filePath,
format,
...(format === "sql" ? { insertMode } : {}),
csvQuoteMode: settingsStore.editorSettings.csvQuoteMode,
columns,
columnComments: format === "xlsx" ? columnComments : undefined,
@@ -26,6 +26,15 @@ describe("ObjectBrowser XLSX export", () => {
expect(exportDataXlsx.indexOf("await showObjectBrowserXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)'));
});
it("asks for the SQL INSERT mode before opening the save dialog", () => {
const exportData = functionBody("exportData");
const exportTableData = functionBody("exportTableData");
expect(exportData).toContain("await showSqlInsertModeDialog()");
expect(exportData.indexOf("await showSqlInsertModeDialog()")).toBeLessThan(exportData.indexOf("await exportTableData("));
expect(exportTableData).toContain('...(format === "sql" ? { insertMode } : {})');
});
it("falls back to field-name headers when column metadata is unavailable", () => {
const exportDataXlsx = functionBody("exportDataXlsx");