diff --git a/apps/desktop/src/components/export/SqlInsertModeDialog.vue b/apps/desktop/src/components/export/SqlInsertModeDialog.vue new file mode 100644 index 0000000000..e2117953b0 --- /dev/null +++ b/apps/desktop/src/components/export/SqlInsertModeDialog.vue @@ -0,0 +1,65 @@ + + + + + + + {{ t("grid.sqlInsertModeTitle") }} + + + {{ t("grid.sqlInsertModePrompt") }} + + + + {{ t("grid.sqlInsertModeBatch") }} + {{ t("grid.sqlInsertModeBatchDescription") }} + + + + + + {{ t("grid.sqlInsertModeSingle") }} + {{ t("grid.sqlInsertModeSingleDescription") }} + + + + + {{ t("common.cancel") }} + {{ t("common.confirm") }} + + + + diff --git a/apps/desktop/src/components/export/__tests__/SqlInsertModeDialog.spec.ts b/apps/desktop/src/components/export/__tests__/SqlInsertModeDialog.spec.ts new file mode 100644 index 0000000000..2626200dff --- /dev/null +++ b/apps/desktop/src/components/export/__tests__/SqlInsertModeDialog.spec.ts @@ -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('input[data-sql-insert-mode="batch"]'); + const single = document.querySelector('input[data-sql-insert-mode="single"]'); + expect(batch?.checked).toBe(true); + expect(single?.checked).toBe(false); + + single?.click(); + await nextTick(); + document.querySelector("[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(); + }); +}); diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0b3fe7f9b2..ac69221801 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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; fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; - queryResultExportRequest?: (options: { exportId: string; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array }) => Promise; + queryResultExportRequest?: (options: { + exportId: string; + filePath: string; + format: "csv" | "xlsx" | "txt" | "sql"; + includeSqlSheet?: boolean; + exportTableName?: string; + exportColumnTypes?: Array; + insertMode?: SqlInsertMode; + }) => Promise; allExportResults?: Array<{ sheetName: string; result: QueryResult; diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index e2dc033f8b..7a450c4b7e 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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 }) => queryStore.buildQueryResultExportRequest(activeTab.id, options) + (options: { exportId: string; filePath: string; format: 'csv' | 'xlsx' | 'txt' | 'sql'; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array; insertMode?: SqlInsertMode }) => + queryStore.buildQueryResultExportRequest(activeTab.id, options) " :all-export-results="allResultExportSheets" :export-file-base-name="activeTab.title" diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 6d95cde1d7..5877f08009 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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 { @@ -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, diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts index 35dc73a75a..5734256f5f 100644 --- a/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts @@ -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"); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 5878952fb1..8664d5498e 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -15,6 +15,7 @@ import { copyToClipboard } from "@/lib/common/clipboard"; import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard"; import { buildDataGridCopyInsertStatement, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats"; +import { showSqlInsertModeDialog, type SqlInsertMode } from "@/lib/export/sqlInsertMode"; import { uuid } from "@/lib/common/utils"; import { useSettingsStore } from "@/stores/settingsStore"; import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue"; @@ -106,7 +107,15 @@ export interface UseDataGridExportOptions { hasRowSelection: ComputedRef; resolveSourceValues?: (rowIds: number[], sourceColumnIndexes: number[]) => Promise>>; fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; - queryResultExportRequest?: (options: { exportId: string; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array }) => Promise; + queryResultExportRequest?: (options: { + exportId: string; + filePath: string; + format: "csv" | "xlsx" | "txt" | "sql"; + includeSqlSheet?: boolean; + exportTableName?: string; + exportColumnTypes?: Array; + insertMode?: SqlInsertMode; + }) => Promise; /** * True when the in-memory result already holds the complete result set — * i.e. the query ran without server-side pagination, was not truncated, and @@ -1104,7 +1113,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { await exportAllResultsXlsxResult(true); } - async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], headerMode: XlsxHeaderMode = "name", autoFilter = true): Promise { + async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode?: SqlInsertMode): Promise { const meta = tableMeta.value; // The backend table exporter currently builds two-part table names. External // Doris/StarRocks catalogs need the data-tab paginator's three-part SQL. @@ -1163,6 +1172,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { tableName: meta.tableName, filePath: outputPath, format, + ...(format === "sql" && insertMode ? { insertMode } : {}), csvQuoteMode: editorSettings.csvQuoteMode, columns: columns.value, columnTypes: columnTypes.value, @@ -1203,7 +1213,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return true; } - async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, headerMode: XlsxHeaderMode = "name", autoFilter = true): Promise { + async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode?: SqlInsertMode): Promise { if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) { return false; } @@ -1234,6 +1244,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { includeSqlSheet, exportTableName: format === "sql" ? tableMeta.value?.tableName : undefined, exportColumnTypes: format === "sql" ? allColumnTypes.value?.map((type) => type ?? null) : undefined, + ...(format === "sql" && insertMode ? { insertMode } : {}), }); const columnComments = format === "xlsx" ? buildXlsxHeaderOverrides(allColumns.value, allXlsxColumnComments.value, headerMode) : undefined; const request = baseRequest @@ -1316,19 +1327,21 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return true; } - async function exportQueryResultSqlViaBackend(rowIds?: number[]): Promise { + async function exportQueryResultSqlViaBackend(rowIds: number[] | undefined, insertMode: SqlInsertMode): Promise { if (!isTauriRuntime()) return false; - return exportQueryResultViaBackend("sql", rowIds); + return exportQueryResultViaBackend("sql", rowIds, false, "name", true, insertMode); } async function exportSql(rowIds?: number[]) { await runExclusiveExport(async () => { + const insertMode = await showSqlInsertModeDialog(); + if (insertMode === null) return; try { // Step 1: table-data context — existing backend table export - if (await exportFullTableDataViaBackend("sql", rowIds)) return; + if (await exportFullTableDataViaBackend("sql", rowIds, "name", true, insertMode)) return; // Step 2: query-result context — NEW backend streaming with background task - if (await exportQueryResultSqlViaBackend(rowIds)) return; + if (await exportQueryResultSqlViaBackend(rowIds, insertMode)) return; // Step 3: fallback — local export (Web and edge-case scenarios) const result = await resultToExport(rowIds, undefined, true, false); @@ -1343,6 +1356,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { spatialColumns: exportData.spatialColumns, spatialValues: exportData.spatialValues, rows: exportData.rows, + insertMode, }); await saveTextFile(content, exportFileName(tableMeta.value?.tableName || "export", "sql", { preferFallback: true }), "SQL", "sql"); toast(t("grid.exported")); @@ -1354,6 +1368,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function exportCurrentPageSql() { await runExclusiveExport(async () => { + const insertMode = await showSqlInsertModeDialog(); + if (insertMode === null) return; try { const result = await resultToExport(undefined, undefined, false, false); const exportData = sqlInsertExportData(result); @@ -1367,6 +1383,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { spatialColumns: exportData.spatialColumns, spatialValues: exportData.spatialValues, rows: exportData.rows, + insertMode, }); await saveTextFile(content, exportFileName("export-page", "sql", { page: true }), "SQL", "sql"); toast(t("grid.exported")); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 01a54464dc..d49afaba9e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1722,6 +1722,12 @@ export default { exportJson: "Export JSON", exportMarkdown: "Export Markdown", exportSql: "Export SQL INSERT", + sqlInsertModeTitle: "SQL INSERT Export Mode", + sqlInsertModePrompt: "Choose how rows are written to the SQL INSERT file:", + sqlInsertModeBatch: "Batch append", + sqlInsertModeBatchDescription: "Combine multiple rows into each INSERT statement for smaller files and faster execution.", + sqlInsertModeSingle: "One row per statement", + sqlInsertModeSingleDescription: "Write one complete INSERT statement per row for easier individual execution and troubleshooting.", exportTxt: "Export TXT", exportSelectedRowsCsv: "Export Selected Rows as CSV", exportSelectedRowsXlsx: "Export Selected Rows as XLSX", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b6e1dbdedf..12fc58568e 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1603,6 +1603,12 @@ export default withEnglishFallback({ exportJson: "Exportar JSON", exportMarkdown: "Exportar Markdown", exportSql: "Exportar SQL INSERT", + sqlInsertModeTitle: "Modo de exportación SQL INSERT", + sqlInsertModePrompt: "Elige cómo se escribirán las filas en el archivo SQL INSERT:", + sqlInsertModeBatch: "Agregar por lotes", + sqlInsertModeBatchDescription: "Combina varias filas en cada instrucción INSERT para obtener archivos más pequeños y una ejecución más rápida.", + sqlInsertModeSingle: "Una fila por instrucción", + sqlInsertModeSingleDescription: "Escribe una instrucción INSERT completa por fila para facilitar la ejecución individual y la resolución de problemas.", exportTxt: "Exportar TXT", exportSelectedRowsCsv: "Exportar filas seleccionadas como CSV", exportSelectedRowsXlsx: "Exportar filas seleccionadas como XLSX", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c47dfefa43..1c71f0a107 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1601,6 +1601,12 @@ export default withEnglishFallback({ exportJson: "Esporta JSON", exportMarkdown: "Esporta Markdown", exportSql: "Esporta SQL INSERT", + sqlInsertModeTitle: "Modalità di esportazione SQL INSERT", + sqlInsertModePrompt: "Scegli come scrivere le righe nel file SQL INSERT:", + sqlInsertModeBatch: "Aggiunta in blocco", + sqlInsertModeBatchDescription: "Combina più righe in ogni istruzione INSERT per ottenere file più piccoli e un'esecuzione più rapida.", + sqlInsertModeSingle: "Una riga per istruzione", + sqlInsertModeSingleDescription: "Scrive un'istruzione INSERT completa per ogni riga per facilitare l'esecuzione singola e la risoluzione dei problemi.", exportTxt: "Esporta TXT", exportSelectedRowsCsv: "Esporta Righe Selezionate come CSV", exportSelectedRowsXlsx: "Esporta Righe Selezionate come XLSX", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 20e5b69035..49e0fbbed1 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1615,6 +1615,12 @@ export default withEnglishFallback({ exportJson: "JSONエクスポート", exportMarkdown: "Markdownエクスポート", exportSql: "SQL INSERTエクスポート", + sqlInsertModeTitle: "SQL INSERT エクスポートモード", + sqlInsertModePrompt: "SQL INSERT ファイルへの行の書き出し方法を選択してください:", + sqlInsertModeBatch: "一括追加", + sqlInsertModeBatchDescription: "複数の行を各 INSERT 文にまとめ、ファイルを小さくして実行を高速化します。", + sqlInsertModeSingle: "1 行ずつ独立した文", + sqlInsertModeSingleDescription: "行ごとに完全な INSERT 文を書き出し、個別の実行やトラブルシューティングを容易にします。", exportTxt: "TXTエクスポート", exportSelectedRowsCsv: "選択行をCSVでエクスポート", exportSelectedRowsXlsx: "選択行をXLSXでエクスポート", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 24dc7f563a..677c3d7323 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1593,6 +1593,12 @@ export default withEnglishFallback({ exportJson: "JSON 내보내기", exportMarkdown: "Markdown 내보내기", exportSql: "SQL INSERT 내보내기", + sqlInsertModeTitle: "SQL INSERT 내보내기 모드", + sqlInsertModePrompt: "SQL INSERT 파일에 행을 기록할 방식을 선택하세요:", + sqlInsertModeBatch: "일괄 추가", + sqlInsertModeBatchDescription: "여러 행을 각 INSERT 문에 결합하여 파일 크기를 줄이고 실행 속도를 높입니다.", + sqlInsertModeSingle: "문 하나당 한 행", + sqlInsertModeSingleDescription: "행마다 완전한 INSERT 문을 작성하여 개별 실행과 문제 해결을 쉽게 합니다.", exportTxt: "TXT 내보내기", exportSelectedRowsCsv: "선택한 행을 CSV로 내보내기", exportSelectedRowsXlsx: "선택한 행을 XLSX로 내보내기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 0edea6b246..c8a6a8e22d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1603,6 +1603,12 @@ export default withEnglishFallback({ exportJson: "Exportar JSON", exportMarkdown: "Exportar Markdown", exportSql: "Exportar SQL INSERT", + sqlInsertModeTitle: "Modo de exportação SQL INSERT", + sqlInsertModePrompt: "Escolha como as linhas serão gravadas no arquivo SQL INSERT:", + sqlInsertModeBatch: "Adição em lote", + sqlInsertModeBatchDescription: "Combina várias linhas em cada instrução INSERT para gerar arquivos menores e executar mais rapidamente.", + sqlInsertModeSingle: "Uma linha por instrução", + sqlInsertModeSingleDescription: "Escreve uma instrução INSERT completa por linha para facilitar a execução individual e a solução de problemas.", exportTxt: "Exportar TXT", exportSelectedRowsCsv: "Exportar Linhas Selecionadas como CSV", exportSelectedRowsXlsx: "Exportar Linhas Selecionadas como XLSX", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index c68b52da73..6c56881be5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1645,6 +1645,12 @@ export default withEnglishFallback({ exportJson: "导出 JSON", exportMarkdown: "导出 Markdown", exportSql: "导出 SQL INSERT", + sqlInsertModeTitle: "SQL INSERT 导出模式", + sqlInsertModePrompt: "请选择 SQL INSERT 文件的写出模式:", + sqlInsertModeBatch: "批量追加", + sqlInsertModeBatchDescription: "将多行数据合并到一条 INSERT 语句,文件更小且执行效率更高。", + sqlInsertModeSingle: "每行独立", + sqlInsertModeSingleDescription: "每行生成一条完整的 INSERT 语句,便于单独执行和排查。", exportTxt: "导出 TXT", exportSelectedRowsCsv: "导出选中行为 CSV", exportSelectedRowsXlsx: "导出选中行为 XLSX", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0e00cec547..56e8ebd014 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1601,6 +1601,12 @@ export default withEnglishFallback({ exportJson: "匯出 JSON", exportMarkdown: "匯出 Markdown", exportSql: "匯出 SQL INSERT", + sqlInsertModeTitle: "SQL INSERT 匯出模式", + sqlInsertModePrompt: "請選擇 SQL INSERT 檔案的資料列寫入方式:", + sqlInsertModeBatch: "批次追加", + sqlInsertModeBatchDescription: "將多筆資料合併到每個 INSERT 陳述式中,讓檔案更小、執行更快。", + sqlInsertModeSingle: "每列一個陳述式", + sqlInsertModeSingleDescription: "每列寫入一個完整的 INSERT 陳述式,方便個別執行與疑難排解。", exportTxt: "匯出 TXT", exportSelectedRowsCsv: "匯出選取資料列為 CSV", exportSelectedRowsXlsx: "匯出選取資料列為 XLSX", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index b1dd2e4d1e..d15172c017 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -9,6 +9,7 @@ import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type Meil import type { XuguTablespaceInfo } from "@/types/database"; import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement"; import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode"; +import type { SqlInsertMode } from "@/lib/export/sqlInsertMode"; /** Normalize Tauri rejections once at the public backend boundary. */ async function invokeBackend(command: string, args?: Record): Promise { @@ -4843,6 +4844,7 @@ export interface TableExportRequest { tableName: string; filePath: string; format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt"; + insertMode?: SqlInsertMode; csvQuoteMode?: CsvQuoteMode; columns?: string[]; columnTypes?: Array; @@ -4892,6 +4894,7 @@ export interface QueryResultExportRequest { useAgentCursor: boolean; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; + insertMode?: SqlInsertMode; csvQuoteMode?: CsvQuoteMode; includeSqlSheet?: boolean; pageSize: number; diff --git a/apps/desktop/src/lib/export/exportFormats.ts b/apps/desktop/src/lib/export/exportFormats.ts index 1ebdfd9efc..7c66b24899 100644 --- a/apps/desktop/src/lib/export/exportFormats.ts +++ b/apps/desktop/src/lib/export/exportFormats.ts @@ -1,6 +1,7 @@ import type { DatabaseType, QueryResult } from "@/types/database"; import * as api from "@/lib/backend/api"; import { escapeCsvField, type CsvQuoteMode } from "@/lib/export/csvQuoteMode"; +import type { SqlInsertMode } from "@/lib/export/sqlInsertMode"; export type ExportCellValue = string | number | boolean | null; @@ -38,11 +39,12 @@ export interface FormatSqlInsertOptions { spatialColumns?: QueryResult["spatial_columns"]; spatialValues?: QueryResult["spatial_values"]; rows: ExportCellValue[][]; + insertMode?: SqlInsertMode; } -export function formatSqlInsert(options: FormatSqlInsertOptions): Promise { +export function formatSqlInsert({ insertMode = "batch", ...options }: FormatSqlInsertOptions): Promise { return api.buildExportSqlInsert({ ...options, - batchSize: 1, + batchSize: insertMode === "single" ? 1 : undefined, }); } diff --git a/apps/desktop/src/lib/export/sqlInsertMode.ts b/apps/desktop/src/lib/export/sqlInsertMode.ts new file mode 100644 index 0000000000..ffc61099b9 --- /dev/null +++ b/apps/desktop/src/lib/export/sqlInsertMode.ts @@ -0,0 +1,32 @@ +import { createApp } from "vue"; +import SqlInsertModeDialog from "@/components/export/SqlInsertModeDialog.vue"; +import i18n from "@/i18n"; + +export type SqlInsertMode = "batch" | "single"; + +export const DEFAULT_SQL_INSERT_MODE: SqlInsertMode = "batch"; + +export function showSqlInsertModeDialog(): Promise { + if (typeof document === "undefined") return Promise.resolve(DEFAULT_SQL_INSERT_MODE); + + return new Promise((resolve) => { + const container = document.createElement("div"); + document.body.append(container); + let settled = false; + let app: ReturnType | null = null; + const finish = (value: SqlInsertMode | null) => { + if (settled) return; + settled = true; + resolve(value); + app?.unmount(); + container.remove(); + }; + app = createApp(SqlInsertModeDialog, { + open: true, + onConfirm: (insertMode: SqlInsertMode) => finish(insertMode), + onCancel: () => finish(null), + }); + app.use(i18n); + app.mount(container); + }); +} diff --git a/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts index 955ae3523d..dcf45bffe3 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts @@ -77,6 +77,31 @@ describe("queryStore query result export", () => { expect(mocks.connectionIdentifierQuote).toHaveBeenCalledWith("kingbase-1"); }); + it("passes the selected SQL INSERT mode to the backend request", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("kingbase-1", "app", "Query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.sql = "SELECT id, name FROM users"; + tab.lastExecutedSql = tab.sql; + tab.result = { + columns: ["id", "name"], + rows: [[1, "Ada"]], + affected_rows: 0, + execution_time_ms: 1, + }; + + const request = await store.buildQueryResultExportRequest(tabId, { + exportId: "export-single", + filePath: "users.sql", + format: "sql", + exportTableName: "users", + insertMode: "single", + }); + + expect(request?.insertMode).toBe("single"); + }); + it("uses the Agent cursor for SQL Server legacy result export", async () => { mocks.getConfig.mockReturnValue({ id: "sqlserver-2000", diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index d3c00829c7..8c730dade5 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -41,6 +41,7 @@ import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; +import type { SqlInsertMode } from "@/lib/export/sqlInsertMode"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, tableMetadataToDataTabMeta, updateCachedTableMetadataType, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; @@ -149,6 +150,7 @@ interface BuildQueryResultExportRequestOptions { includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array; + insertMode?: SqlInsertMode; } interface OpenSavedSqlOptions { @@ -7558,6 +7560,7 @@ export const useQueryStore = defineStore("query", () => { useAgentCursor, filePath: options.filePath, format: options.format, + ...(options.format === "sql" && options.insertMode ? { insertMode: options.insertMode } : {}), includeSqlSheet: options.format === "xlsx" && options.includeSqlSheet === true, pageSize: settings.exportBatchSize, rowLimit, diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index d16dd35173..8483ba76b3 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -42,6 +42,28 @@ pub enum DatabaseExportOutputCompression { Gzip, } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SqlInsertMode { + #[default] + Batch, + Single, +} + +impl SqlInsertMode { + pub(crate) const fn flush_each_row(self) -> bool { + matches!(self, Self::Single) + } + + pub(crate) const fn batch_size(self, default: usize) -> usize { + if self.flush_each_row() || default == 0 { + 1 + } else { + default + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DatabaseExportRequest { diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs index a97b50f184..86eed26375 100644 --- a/crates/dbx-core/src/query_result_export.rs +++ b/crates/dbx-core/src/query_result_export.rs @@ -15,7 +15,9 @@ use crate::csv_export::{ CsvQuoteMode, }; pub use crate::database_export::ExportStatus; -use crate::database_export::{build_export_insert_statements, is_export_cancelled, BuildExportInsertStatementsOptions}; +use crate::database_export::{ + build_export_insert_statements, is_export_cancelled, BuildExportInsertStatementsOptions, SqlInsertMode, +}; use crate::models::connection::DatabaseType; use crate::query::{ await_stream_with_progress_timeout, canceled_error, close_query_session, execute_sql_statement_with_options, @@ -82,6 +84,8 @@ pub struct QueryResultExportRequest { pub file_path: String, pub format: String, #[serde(default)] + pub insert_mode: SqlInsertMode, + #[serde(default)] pub csv_quote_mode: CsvQuoteMode, #[serde(default)] pub include_sql_sheet: bool, @@ -300,8 +304,9 @@ fn sql_insert_column_types(request: &QueryResultExportRequest, column_types: &[S /// Bounded SQL INSERT writer with staged-file replacement safety. /// -/// Rows are buffered and flushed to a temp file every [`SQL_INSERT_BATCH_SIZE`] -/// rows, so memory stays bounded regardless of the query page size. The unique +/// Batch mode buffers rows up to [`SQL_INSERT_BATCH_SIZE`], while single mode +/// flushes each row immediately. Both modes keep memory bounded regardless of +/// the query page size. The unique /// temp file lives alongside the target and replaces it only after /// [`SqlInsertWriter::finish`] flushes and synchronizes the complete output. struct SqlInsertWriter { @@ -309,6 +314,7 @@ struct SqlInsertWriter { target: Option, pending_rows: Vec>, pending_spatial_values: Vec>>, + insert_mode: SqlInsertMode, columns: Vec, column_types: Vec>, spatial_columns: Vec, @@ -337,6 +343,7 @@ impl SqlInsertWriter { target: Some(target), pending_rows: Vec::new(), pending_spatial_values: Vec::new(), + insert_mode: request.insert_mode, columns: Vec::new(), column_types: Vec::new(), spatial_columns: Vec::new(), @@ -365,7 +372,7 @@ impl SqlInsertWriter { fn write_row(&mut self, row: Vec, spatial_values: Option>>) -> Result<(), String> { self.pending_rows.push(row); self.pending_spatial_values.push(spatial_values.unwrap_or_default()); - if self.pending_rows.len() >= SQL_INSERT_BATCH_SIZE { + if self.insert_mode.flush_each_row() || self.pending_rows.len() >= SQL_INSERT_BATCH_SIZE { self.flush_batch()?; } Ok(()) @@ -387,7 +394,7 @@ impl SqlInsertWriter { spatial_columns: self.spatial_columns.clone(), spatial_values: mem::take(&mut self.pending_spatial_values), rows: mem::take(&mut self.pending_rows), - batch_size: Some(SQL_INSERT_BATCH_SIZE), + batch_size: Some(self.insert_mode.batch_size(SQL_INSERT_BATCH_SIZE)), })?; let file = self.file.as_mut().ok_or_else(|| "SQL export file already closed".to_string())?; for stmt in &stmts { @@ -1965,6 +1972,7 @@ mod tests { use_agent_cursor: false, file_path: "out.csv".to_string(), format: format.to_string(), + insert_mode: SqlInsertMode::default(), include_sql_sheet: false, page_size: 1000, row_limit, @@ -1984,6 +1992,55 @@ mod tests { } } + fn rendered_sql_insert_output(mode: SqlInsertMode) -> String { + let dir = tempfile::tempdir().expect("temp dir"); + let destination = dir.path().join("users.sql"); + let mut req = request("sql", None, None); + req.database_type = DatabaseType::Mysql; + req.file_path = destination.to_string_lossy().into_owned(); + req.export_table_name = Some("users".to_string()); + req.insert_mode = mode; + + let mut writer = SqlInsertWriter::create(&req).expect("create SQL writer"); + writer.set_columns( + vec!["id".to_string(), "name".to_string()], + &["int".to_string(), "text".to_string()], + &[], + &req, + ); + writer.write_row(vec![serde_json::json!(1), serde_json::json!("Ada")], None).expect("write first row"); + writer.write_row(vec![serde_json::json!(2), serde_json::json!("Lin")], None).expect("write second row"); + writer.finish().expect("finish SQL writer"); + + std::fs::read_to_string(destination).expect("read SQL output") + } + + #[test] + fn sql_insert_mode_defaults_to_batch_when_request_field_is_missing() { + let mut serialized = serde_json::to_value(request("sql", None, None)).expect("serialize request"); + serialized.as_object_mut().expect("request object").remove("insertMode"); + let decoded: QueryResultExportRequest = serde_json::from_value(serialized).expect("deserialize request"); + + assert_eq!(decoded.insert_mode, SqlInsertMode::Batch); + } + + #[test] + fn sql_insert_writer_uses_one_batched_statement_by_default() { + let output = rendered_sql_insert_output(SqlInsertMode::Batch); + + assert_eq!(output.matches("INSERT INTO").count(), 1); + assert!(output.contains("VALUES (1, 'Ada'), (2, 'Lin');")); + } + + #[test] + fn sql_insert_writer_emits_one_complete_statement_per_row_in_single_mode() { + let output = rendered_sql_insert_output(SqlInsertMode::Single); + + assert_eq!(output.matches("INSERT INTO").count(), 2); + assert!(output.contains("VALUES (1, 'Ada');\nINSERT INTO `users` (`id`, `name`) VALUES (2, 'Lin');")); + assert!(!output.contains("), (")); + } + #[test] fn highgo_unrewritable_query_export_reports_actionable_pagination_error() { let mut req = request("csv", None, None); diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index 154ceec8b0..3725ac7e20 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -20,6 +20,7 @@ use crate::csv_export::{ pub use crate::database_export::ExportStatus; use crate::database_export::{ build_export_insert_statements, is_export_cancelled, is_internal_export_column, BuildExportInsertStatementsOptions, + SqlInsertMode, }; use crate::db::agent_driver::AgentTableReadStartParams; use crate::models::connection::DatabaseType; @@ -54,6 +55,8 @@ pub struct TableExportRequest { /// "csv", "xlsx", "json", "markdown", "sql", or "txt" pub format: String, #[serde(default)] + pub insert_mode: SqlInsertMode, + #[serde(default)] pub csv_quote_mode: CsvQuoteMode, #[serde(default)] pub columns: Option>, @@ -1268,7 +1271,7 @@ async fn try_export_native_table_stream( spatial_columns: Vec::new(), spatial_values: Vec::new(), rows: std::mem::take(pending_rows), - batch_size: Some(SQL_INSERT_BATCH_SIZE), + batch_size: Some(request.insert_mode.batch_size(SQL_INSERT_BATCH_SIZE)), })?; if !statements.is_empty() { if wrote_statements { @@ -1290,7 +1293,7 @@ async fn try_export_native_table_stream( cancel_token.clone(), |row| { pending_rows.push(row.to_vec()); - if pending_rows.len() >= SQL_INSERT_BATCH_SIZE { + if request.insert_mode.flush_each_row() || pending_rows.len() >= SQL_INSERT_BATCH_SIZE { flush_pending(&mut file, &mut pending_rows)?; } rows_exported += 1; @@ -2102,7 +2105,7 @@ async fn export_table_data_core_inner( spatial_columns: result.spatial_columns.clone(), spatial_values: result.spatial_values.clone(), rows: result.rows.clone(), - batch_size: Some(100), + batch_size: Some(request.insert_mode.batch_size(SQL_INSERT_BATCH_SIZE)), })?; if !statements.is_empty() { if wrote_statements { @@ -2177,6 +2180,22 @@ mod tests { #[cfg(unix)] use std::os::unix::fs::PermissionsExt; + #[test] + fn table_export_request_defaults_to_batch_insert_mode() { + let request: TableExportRequest = serde_json::from_value(json!({ + "exportId": "export-1", + "connectionId": "conn-1", + "database": "db", + "schema": null, + "tableName": "users", + "filePath": "users.sql", + "format": "sql" + })) + .expect("deserialize table export request"); + + assert_eq!(request.insert_mode, SqlInsertMode::Batch); + } + #[cfg(unix)] struct ExternalDriverExportFixture { state: AppState, @@ -2266,6 +2285,7 @@ mod tests { table_name: "EXPORT_SAMPLE".to_string(), file_path: output.to_string_lossy().into_owned(), format: "csv".to_string(), + insert_mode: Default::default(), columns: Some(vec!["id".to_string(), "name".to_string()]), column_types: Some(vec![Some("INTEGER".to_string()), Some("VARCHAR".to_string())]), primary_keys: Some(vec!["id".to_string()]), @@ -2427,6 +2447,7 @@ mod tests { table_name: "weather".to_string(), file_path: output.to_string_lossy().into_owned(), format: "csv".to_string(), + insert_mode: Default::default(), csv_quote_mode: CsvQuoteMode::All, columns: None, column_types: None, @@ -2591,6 +2612,7 @@ mod tests { table_name: "device2".to_string(), file_path: "device2.csv".to_string(), format: "csv".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2648,6 +2670,7 @@ mod tests { table_name: "device2".to_string(), file_path: "device2.csv".to_string(), format: "csv".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2683,6 +2706,7 @@ mod tests { table_name: "device2".to_string(), file_path: "device2.csv".to_string(), format: "csv".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2713,6 +2737,7 @@ mod tests { table_name: "device2".to_string(), file_path: "device2.csv".to_string(), format: "csv".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2751,6 +2776,7 @@ mod tests { table_name: "samples".to_string(), file_path: "samples.csv".to_string(), format: "csv".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2801,6 +2827,7 @@ mod tests { table_name: "events".to_string(), file_path: "events.csv".to_string(), format: "csv".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2845,6 +2872,7 @@ mod tests { table_name: "orders".to_string(), file_path: "orders.txt".to_string(), format: "txt".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2917,6 +2945,7 @@ mod tests { table_name: "order".to_string(), file_path: "order.csv".to_string(), format: "csv".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -2971,6 +3000,7 @@ mod tests { table_name: "spatial_data".to_string(), file_path: "spatial_data.sql".to_string(), format: "sql".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, @@ -3027,6 +3057,7 @@ mod tests { table_name: "USERS".to_string(), file_path: "users.sql".to_string(), format: "sql".to_string(), + insert_mode: Default::default(), columns: None, column_types: None, primary_keys: None, diff --git a/crates/dbx-core/tests/live_clickhouse_query_result_export.rs b/crates/dbx-core/tests/live_clickhouse_query_result_export.rs index 3e30d03cf8..9d75245687 100644 --- a/crates/dbx-core/tests/live_clickhouse_query_result_export.rs +++ b/crates/dbx-core/tests/live_clickhouse_query_result_export.rs @@ -92,6 +92,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "xlsx".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 100, row_limit: None, diff --git a/crates/dbx-core/tests/live_mysql57.rs b/crates/dbx-core/tests/live_mysql57.rs index f1ce6b758e..88ee0fe0f9 100644 --- a/crates/dbx-core/tests/live_mysql57.rs +++ b/crates/dbx-core/tests/live_mysql57.rs @@ -465,6 +465,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "xlsx".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 50, row_limit: None, @@ -558,6 +559,7 @@ async fn live_mysql_xlsx_export_can_outlive_query_timeout_while_rows_keep_arrivi use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "xlsx".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 10_000, row_limit: None, diff --git a/crates/dbx-core/tests/live_postgres_query_result_export.rs b/crates/dbx-core/tests/live_postgres_query_result_export.rs index d94a214edc..05fe1f1c94 100644 --- a/crates/dbx-core/tests/live_postgres_query_result_export.rs +++ b/crates/dbx-core/tests/live_postgres_query_result_export.rs @@ -134,6 +134,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() { use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "csv".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 100, row_limit: None, @@ -217,6 +218,7 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() { use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "xlsx".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 100, row_limit: None, @@ -289,6 +291,7 @@ async fn live_postgres_numeric_xlsx_ignores_fractional_trailing_zeros() { use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "xlsx".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 100, row_limit: None, @@ -366,6 +369,7 @@ async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() { use_agent_cursor: false, file_path: dir.join("result.csv").to_string_lossy().to_string(), format: "csv".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 2000, row_limit: None, @@ -445,6 +449,7 @@ async fn live_postgres_xlsx_export_can_outlive_query_timeout_while_rows_keep_arr use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "xlsx".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 10_000, row_limit: None, @@ -515,6 +520,7 @@ async fn live_postgres_stream_still_times_out_without_progress_and_recovers() { use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "csv".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 100, row_limit: None, diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index b9d54154cc..8b3e3fecfd 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -1196,6 +1196,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() { use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "csv".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 1, row_limit: None, diff --git a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs index 9ae57ccba5..1e2bf5035b 100644 --- a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs +++ b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs @@ -113,6 +113,7 @@ async fn live_sqlserver_xlsx_export_can_outlive_query_timeout_while_rows_keep_ar use_agent_cursor: false, file_path: file_path.to_string_lossy().to_string(), format: "xlsx".to_string(), + insert_mode: Default::default(), include_sql_sheet: false, page_size: 5000, row_limit: Some(200_000), diff --git a/packages/app-tests/useDataGridExport.test.ts b/packages/app-tests/useDataGridExport.test.ts index be26932220..bc70daed49 100644 --- a/packages/app-tests/useDataGridExport.test.ts +++ b/packages/app-tests/useDataGridExport.test.ts @@ -24,6 +24,7 @@ const clipboardMock = vi.hoisted(() => ({ copyToClipboard: vi.fn(), })); const runtimeMock = vi.hoisted(() => ({ isTauri: false })); +const sqlInsertModeMock = vi.hoisted(() => ({ showSqlInsertModeDialog: vi.fn() })); const dialogMock = vi.hoisted(() => ({ save: vi.fn() })); const toastMock = vi.hoisted(() => vi.fn()); const translateMock = vi.hoisted(() => @@ -35,6 +36,7 @@ const translateMock = vi.hoisted(() => ); vi.mock("@/lib/backend/api", () => apiMock); +vi.mock("@/lib/export/sqlInsertMode", () => sqlInsertModeMock); vi.mock("@/lib/common/clipboard", () => clipboardMock); vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => runtimeMock.isTauri })); vi.mock("@tauri-apps/plugin-dialog", () => ({ save: dialogMock.save })); @@ -130,7 +132,7 @@ function buildExportHarness( const fullExportResult = vi.fn(async () => { throw new Error("fullExportResult should not be called for streaming CSV/XLSX query exports"); }); - const queryResultExportRequest = vi.fn(async (options: { exportId: string; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array }) => ({ + const queryResultExportRequest = vi.fn(async (options: { exportId: string; filePath: string; format: "csv" | "xlsx" | "txt" | "sql"; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array; insertMode?: "batch" | "single" }) => ({ exportId: options.exportId, connectionId: "conn-1", database: "db", @@ -144,6 +146,7 @@ function buildExportHarness( includeSqlSheet: options.includeSqlSheet, exportTableName: options.exportTableName, exportColumnTypes: options.exportColumnTypes, + ...(options.insertMode ? { insertMode: options.insertMode } : {}), pageSize: 1000, rowLimit: 100000, totalRows: 2, @@ -262,6 +265,7 @@ beforeEach(() => { vi.clearAllMocks(); runtimeMock.isTauri = false; dialogMock.save.mockResolvedValue(null); + sqlInsertModeMock.showSqlInsertModeDialog.mockResolvedValue("batch"); clipboardMock.copyToClipboard.mockResolvedValue(undefined); apiMock.startQueryResultExport.mockImplementation(async (_request, onProgress) => { onProgress({ exportId: _request.exportId, tableName: "", rowsExported: 2, totalRows: 2, status: "Done" }); @@ -489,6 +493,105 @@ test("MySQL joined query SQL export keeps result aliases instead of source colum } }); +test("non-SQL query exports do not forward the INSERT mode", async () => { + runtimeMock.isTauri = true; + dialogMock.save.mockResolvedValue("/tmp/query-result.csv"); + const { composable, queryResultExportRequest } = buildExportHarness(); + + await composable.exportCsv(); + + assert.equal("insertMode" in queryResultExportRequest.mock.calls[0][0], false); + assert.equal("insertMode" in apiMock.startQueryResultExport.mock.calls[0][0], false); +}); + +test("SQL export uses batch mode by default for query-result streaming", async () => { + runtimeMock.isTauri = true; + dialogMock.save.mockResolvedValue("/tmp/query-result.sql"); + const { composable, queryResultExportRequest } = buildExportHarness(); + + await composable.exportSql(); + + assert.equal(sqlInsertModeMock.showSqlInsertModeDialog.mock.calls.length, 1); + assert.equal(queryResultExportRequest.mock.calls[0][0].insertMode, "batch"); + assert.equal(apiMock.startQueryResultExport.mock.calls[0][0].insertMode, "batch"); +}); + +test("SQL export forwards the selected single-row mode to query-result streaming", async () => { + runtimeMock.isTauri = true; + dialogMock.save.mockResolvedValue("/tmp/query-result.sql"); + sqlInsertModeMock.showSqlInsertModeDialog.mockResolvedValueOnce("single"); + const { composable, queryResultExportRequest } = buildExportHarness(); + + await composable.exportSql(); + + assert.equal(queryResultExportRequest.mock.calls[0][0].insertMode, "single"); + assert.equal(apiMock.startQueryResultExport.mock.calls[0][0].insertMode, "single"); +}); + +test("table data SQL export forwards the selected mode to the table backend", async () => { + runtimeMock.isTauri = true; + dialogMock.save.mockResolvedValue("/tmp/users.sql"); + sqlInsertModeMock.showSqlInsertModeDialog.mockResolvedValueOnce("single"); + const { composable } = buildTableDataExportHarness(); + + await composable.exportSql(); + + assert.equal(apiMock.startTableExport.mock.calls[0][0].format, "sql"); + assert.equal(apiMock.startTableExport.mock.calls[0][0].insertMode, "single"); +}); + +test("local SQL export maps the selected mode to the shared formatter", async () => { + const download = installTextDownloadCapture(); + sqlInsertModeMock.showSqlInsertModeDialog.mockResolvedValueOnce("single"); + apiMock.buildExportSqlInsert.mockResolvedValueOnce("INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada');"); + + try { + const completeLocalResult: QueryResult = { + columns: ["id", "name"], + column_types: ["int", "text"], + rows: [[1, "Ada"]], + affected_rows: 0, + execution_time_ms: 1, + truncated: false, + has_more: false, + }; + const { composable } = buildExportHarness({ completeLocalResult }); + + await composable.exportSql(); + + assert.equal(apiMock.buildExportSqlInsert.mock.calls[0][0].batchSize, 1); + } finally { + download.restore(); + } +}); + +test("local SQL export defaults to the shared batch formatter", async () => { + const download = installTextDownloadCapture(); + apiMock.buildExportSqlInsert.mockResolvedValueOnce("INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'Lin');"); + + try { + const completeLocalResult: QueryResult = { + columns: ["id", "name"], + column_types: ["int", "text"], + rows: [ + [1, "Ada"], + [2, "Lin"], + ], + affected_rows: 0, + execution_time_ms: 1, + truncated: false, + has_more: false, + }; + const { composable } = buildExportHarness({ completeLocalResult }); + + await composable.exportSql(); + + assert.equal(apiMock.buildExportSqlInsert.mock.calls[0][0].batchSize, undefined); + } finally { + download.restore(); + } +}); + test("background SQL export keeps full result column types when visible columns are reordered", async () => { runtimeMock.isTauri = true; dialogMock.save.mockResolvedValue("/tmp/query-result.sql");
{{ t("grid.sqlInsertModePrompt") }}