diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue
index a3e413f4ce..bfce98ebb6 100644
--- a/apps/desktop/src/components/export/DatabaseExportDialog.vue
+++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue
@@ -68,6 +68,10 @@ const includeObjects = ref(true);
const includeCreateDatabase = ref(false);
const dropTableIfExists = ref(false);
const omitAutoIncrement = ref(false);
+const splitSqlOutput = ref(false);
+const splitSqlPartMaxMb = ref(100);
+const MIN_SPLIT_SQL_PART_MB = 1;
+const MAX_SPLIT_SQL_PART_MB = 4096;
// `AUTO_INCREMENT` stripping is a MySQL-only DDL transform (backend gates on
// db_type == mysql, which also covers MariaDB / TiDB / OceanBase-MySQL-mode).
const isMysqlFamily = computed(() => store.getConfig(connectionId.value)?.db_type === "mysql");
@@ -161,6 +165,12 @@ function joinExportPath(directory: string, fileName: string): string {
return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`;
}
+function normalizedSplitSqlPartMaxMb(): number {
+ const value = Number(splitSqlPartMaxMb.value);
+ if (!Number.isFinite(value)) return 100;
+ return Math.min(MAX_SPLIT_SQL_PART_MB, Math.max(MIN_SPLIT_SQL_PART_MB, Math.round(value)));
+}
+
// Lenient exports write per-object failures into the SQL file as `-- ERROR`
// comments and still finish; completion must warn instead of reporting plain
// success (#8184).
@@ -329,8 +339,8 @@ async function startExport() {
const { save } = await import("@tauri-apps/plugin-dialog");
const safeName = sanitizeFileName(database.value || "database");
const path = await save({
- defaultPath: `${safeName}.sql`,
- filters: [{ name: "SQL", extensions: ["sql"] }],
+ defaultPath: `${safeName}.${splitSqlOutput.value ? "zip" : "sql"}`,
+ filters: [{ name: splitSqlOutput.value ? "ZIP" : "SQL", extensions: [splitSqlOutput.value ? "zip" : "sql"] }],
});
if (!path) return;
filePath = path;
@@ -392,6 +402,7 @@ async function startExport() {
omitAutoIncrement: omitAutoIncrement.value,
snapshotSessionId,
batchSize: 1000,
+ splitMaxMb: splitSqlOutput.value ? normalizedSplitSqlPartMaxMb() : undefined,
};
return runDatabaseExportUntilTerminal(request, (progress) => {
exportProgress.value = { ...progress };
@@ -508,7 +519,7 @@ async function startAllDatabasesExport() {
batchDatabaseIndex.value = index + 1;
const currentExportId = `${batchId}-${index + 1}`;
activeDatabaseExportId.value = currentExportId;
- const filePath = isTauriRuntime() ? joinExportPath(directoryPath, `${sanitizeFileName(item.fileStem)}.sql`) : `__web_export_${currentExportId}.sql`;
+ const filePath = isTauriRuntime() ? joinExportPath(directoryPath, `${sanitizeFileName(item.fileStem)}.${splitSqlOutput.value ? "zip" : "sql"}`) : `__web_export_${currentExportId}.${splitSqlOutput.value ? "zip" : "sql"}`;
let currentDatabaseRowsExported = 0;
const terminal = await runWithDatabaseBackupSnapshot(
@@ -533,6 +544,7 @@ async function startAllDatabasesExport() {
omitAutoIncrement: omitAutoIncrement.value,
snapshotSessionId,
batchSize: 1000,
+ splitMaxMb: splitSqlOutput.value ? normalizedSplitSqlPartMaxMb() : undefined,
},
(progress) => {
const nextRowsExported = Math.max(0, progress.rowsExported);
@@ -651,6 +663,8 @@ function resetState() {
includeCreateDatabase.value = false;
dropTableIfExists.value = false;
omitAutoIncrement.value = false;
+ splitSqlOutput.value = false;
+ splitSqlPartMaxMb.value = 100;
isExporting.value = false;
exportProgress.value = null;
exportDone.value = false;
@@ -938,6 +952,27 @@ watch(
{{ t("databaseExport.includeObjects") }}
+
+
+
+
+
+ {{ t("databaseExport.splitSqlOutputDescription", { min: MIN_SPLIT_SQL_PART_MB, max: MAX_SPLIT_SQL_PART_MB }) }}
+
diff --git a/apps/desktop/src/components/export/SqlInsertModeDialog.vue b/apps/desktop/src/components/export/SqlInsertModeDialog.vue
index e2117953b0..29dd483ed7 100644
--- a/apps/desktop/src/components/export/SqlInsertModeDialog.vue
+++ b/apps/desktop/src/components/export/SqlInsertModeDialog.vue
@@ -3,22 +3,36 @@ 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";
+import type { SqlExportOptions, SqlInsertMode } from "@/lib/export/sqlInsertMode";
const { t } = useI18n();
const open = defineModel("open", { default: false });
+const props = defineProps<{ allowSplit?: boolean }>();
const selected = ref("batch");
+const splitSqlOutput = ref(false);
+const splitSqlPartMaxMb = ref(100);
+const MIN_SPLIT_SQL_PART_MB = 1;
+const MAX_SPLIT_SQL_PART_MB = 4096;
let outcomeEmitted = false;
const emit = defineEmits<{
- confirm: [mode: SqlInsertMode];
+ confirm: [options: SqlExportOptions];
cancel: [];
}>();
+function normalizedSplitSqlPartMaxMb(): number {
+ const value = Number(splitSqlPartMaxMb.value);
+ if (!Number.isFinite(value)) return 100;
+ return Math.min(MAX_SPLIT_SQL_PART_MB, Math.max(MIN_SPLIT_SQL_PART_MB, Math.round(value)));
+}
+
function onConfirm() {
outcomeEmitted = true;
open.value = false;
- emit("confirm", selected.value);
+ emit("confirm", {
+ insertMode: selected.value,
+ splitMaxMb: props.allowSplit && splitSqlOutput.value ? normalizedSplitSqlPartMaxMb() : undefined,
+ });
}
function onCancel() {
@@ -55,6 +69,19 @@ function onOpenChange(value: boolean) {
{{ t("grid.sqlInsertModeSingleDescription") }}
+
+
+
+ {{ t("databaseExport.splitSqlPartMaxMb") }}
+
+
+
+ {{ t("databaseExport.splitSqlOutputDescription", { min: MIN_SPLIT_SQL_PART_MB, max: MAX_SPLIT_SQL_PART_MB }) }}
+
+
diff --git a/apps/desktop/src/components/export/__tests__/SqlInsertModeDialog.spec.ts b/apps/desktop/src/components/export/__tests__/SqlInsertModeDialog.spec.ts
index 2626200dff..d8e6c53e30 100644
--- a/apps/desktop/src/components/export/__tests__/SqlInsertModeDialog.spec.ts
+++ b/apps/desktop/src/components/export/__tests__/SqlInsertModeDialog.spec.ts
@@ -37,13 +37,13 @@ afterEach(() => {
i18n.global.locale.value = "en";
});
-async function mountDialog(onConfirm = () => {}, onCancel = () => {}) {
+async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit = false) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(
defineComponent({
setup() {
- return () => h(SqlInsertModeDialog, { open: true, onConfirm, onCancel });
+ return () => h(SqlInsertModeDialog, { open: true, allowSplit, onConfirm, onCancel });
},
}),
);
@@ -68,7 +68,24 @@ describe("SqlInsertModeDialog", () => {
document.querySelector("[data-sql-insert-mode-confirm]")?.click();
expect(onConfirm).toHaveBeenCalledOnce();
- expect(onConfirm).toHaveBeenCalledWith("single");
+ expect(onConfirm).toHaveBeenCalledWith({ insertMode: "single", splitMaxMb: undefined });
+ });
+
+ it("enables split ZIP output only when the caller supports it", async () => {
+ const onConfirm = vi.fn();
+ await mountDialog(onConfirm, () => {}, true);
+
+ const split = document.querySelector("input[data-sql-split-output]");
+ expect(split).not.toBeNull();
+ split?.click();
+ await nextTick();
+ const size = document.querySelector("input[data-sql-split-max-mb]");
+ expect(size?.value).toBe("100");
+ if (size) size.value = "256";
+ size?.dispatchEvent(new Event("input", { bubbles: true }));
+ document.querySelector("[data-sql-insert-mode-confirm]")?.click();
+
+ expect(onConfirm).toHaveBeenCalledWith({ insertMode: "batch", splitMaxMb: 256 });
});
it("renders the batch and single-row explanations", async () => {
diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue
index d6ea061774..fe56730348 100644
--- a/apps/desktop/src/components/objects/ObjectBrowser.vue
+++ b/apps/desktop/src/components/objects/ObjectBrowser.vue
@@ -2112,9 +2112,9 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql")
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");
+ const sqlExportOptions = format === "sql" ? await showSqlInsertModeDialog({ allowSplit: true }) : undefined;
+ if (format === "sql" && sqlExportOptions === null) return;
+ await exportTableData(row, format, undefined, "name", true, sqlExportOptions?.insertMode ?? "batch", sqlExportOptions?.splitMaxMb);
}
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise {
@@ -2157,17 +2157,18 @@ 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, insertMode: SqlInsertMode = "batch") {
+async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode: SqlInsertMode = "batch", splitMaxMb?: number) {
const schema = row.schema || selectedSchema.value;
+ const splitSqlOutput = format === "sql" && splitMaxMb !== undefined;
// Save dialog first
let filePath = "";
- const defaultName = `${row.name}.${format}`;
+ const defaultName = `${row.name}.${splitSqlOutput ? "zip" : format}`;
if (isTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
- const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : format === "xlsx" ? { name: "Excel", extensions: ["xlsx"] } : { name: "SQL", extensions: ["sql"] };
+ const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : format === "xlsx" ? { name: "Excel", extensions: ["xlsx"] } : splitSqlOutput ? { name: "ZIP", extensions: ["zip"] } : { name: "SQL", extensions: ["sql"] };
const path = await save({
defaultPath: defaultName,
filters: [filter],
@@ -2180,7 +2181,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
}
} else {
const webExportId = generateDatabaseExportId();
- filePath = `__web_export_${webExportId}.${format}`;
+ filePath = `__web_export_${webExportId}.${splitSqlOutput ? "zip" : format}`;
}
let task: ExportTask | null = null;
@@ -2231,7 +2232,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
tableName: row.name,
filePath,
format,
- ...(format === "sql" ? { insertMode } : {}),
+ ...(format === "sql" ? { insertMode, splitMaxMb } : {}),
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 5734256f5f..e578c96286 100644
--- a/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts
+++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts
@@ -30,9 +30,9 @@ describe("ObjectBrowser XLSX export", () => {
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 } : {})');
+ expect(exportData).toContain("await showSqlInsertModeDialog({ allowSplit: true })");
+ expect(exportData.indexOf("await showSqlInsertModeDialog({ allowSplit: true })")).toBeLessThan(exportData.indexOf("await exportTableData("));
+ expect(exportTableData).toContain('...(format === "sql" ? { insertMode, splitMaxMb } : {})');
});
it("falls back to field-name headers when column metadata is unavailable", () => {
diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts
index 711240b71b..b6eb87cc5a 100644
--- a/apps/desktop/src/composables/useDataGridExport.ts
+++ b/apps/desktop/src/composables/useDataGridExport.ts
@@ -15,7 +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 { showSqlInsertModeDialog, type SqlExportOptions, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { summarizeExportRows } from "@/lib/export/exportDiagnostics";
import { appendDebugLog, appendNativeProcessMemoryLog, getBrowserMemorySnapshot, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { uuid } from "@/lib/common/utils";
@@ -1164,7 +1164,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, insertMode?: SqlInsertMode): Promise {
+ async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], headerMode: XlsxHeaderMode = "name", autoFilter = true, sqlExportOptions?: SqlExportOptions): 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.
@@ -1173,8 +1173,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
const fmt = FORMAT_META[format];
- const extension = fmt?.ext ?? format;
- const filterName = fmt?.label ?? format.toUpperCase();
+ const splitSqlOutput = format === "sql" && sqlExportOptions?.splitMaxMb !== undefined;
+ const extension = splitSqlOutput ? "zip" : (fmt?.ext ?? format);
+ const filterName = splitSqlOutput ? "ZIP" : (fmt?.label ?? format.toUpperCase());
let outputPath = exportFileName(meta.tableName || "export", extension, { preferFallback: true });
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
@@ -1223,7 +1224,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
tableName: meta.tableName,
filePath: outputPath,
format,
- ...(format === "sql" && insertMode ? { insertMode } : {}),
+ ...(format === "sql" && sqlExportOptions ? { insertMode: sqlExportOptions.insertMode, splitMaxMb: sqlExportOptions.splitMaxMb } : {}),
csvQuoteMode: editorSettings.csvQuoteMode,
columns: columns.value,
columnTypes: columnTypes.value,
@@ -1409,16 +1410,18 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
},
true,
);
- const insertMode = await showSqlInsertModeDialog();
- if (insertMode === null) {
+ const selectedSqlExportOptions = (await showSqlInsertModeDialog({ allowSplit: rowIds === undefined && context.value === "table-data" })) as SqlExportOptions | SqlInsertMode | null;
+ if (selectedSqlExportOptions === null) {
logExportStage("cancelled", { stage: "insert-mode-dialog" });
return;
}
- logExportStage("mode-selected", { insertMode });
+ const sqlExportOptions = typeof selectedSqlExportOptions === "string" ? { insertMode: selectedSqlExportOptions } : selectedSqlExportOptions;
+ const insertMode = sqlExportOptions.insertMode;
+ logExportStage("mode-selected", { insertMode, splitMaxMb: sqlExportOptions.splitMaxMb });
try {
// Step 1: table-data context — existing backend table export
logExportStage("backend-export-start");
- const handledByBackend = await exportFullTableDataViaBackend("sql", rowIds, "name", true, insertMode);
+ const handledByBackend = await exportFullTableDataViaBackend("sql", rowIds, "name", true, sqlExportOptions);
logExportStage("backend-export-finished", { handledByBackend });
if (handledByBackend) {
logExportStage("done", { path: "backend" });
@@ -1490,8 +1493,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function exportCurrentPageSql() {
await runExclusiveExport(async () => {
- const insertMode = await showSqlInsertModeDialog();
- if (insertMode === null) return;
+ const selectedSqlExportOptions = (await showSqlInsertModeDialog()) as SqlExportOptions | SqlInsertMode | null;
+ if (selectedSqlExportOptions === null) return;
+ const insertMode = typeof selectedSqlExportOptions === "string" ? selectedSqlExportOptions : selectedSqlExportOptions.insertMode;
try {
const result = await resultToExport(undefined, undefined, false, false);
const exportData = sqlInsertExportData(result);
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts
index f04cc14eb2..08db06c5e1 100644
--- a/apps/desktop/src/i18n/locales/az.ts
+++ b/apps/desktop/src/i18n/locales/az.ts
@@ -7881,6 +7881,9 @@ export default withEnglishFallback({
omitAutoIncrement: "AUTO_INCREMENT parametrini daxil etmə (yeni quraşdırma skriptləri üçün)",
includeData: "Cədvəl məlumatları (INSERT)",
includeObjects: "Görünüşlər / prosedurlar / funksiyalar / ardıcıllıqlar",
+ splitSqlOutput: "SQL ixracını ZIP arxivinə böl",
+ splitSqlPartMaxMb: "Hər SQL hissəsi üçün maksimum ölçü (MB)",
+ splitSqlOutputDescription: "Nömrələnmiş SQL hissələri və manifestdən ibarət ZIP arxivi yaradır. Hissələr tam SQL ifadələri arasında bölünür ({min}–{max} MB).",
databaseSelection: "Verilənlər bazaları",
selectedDatabases: "{selected}/{total} seçilib",
selectAllDatabases: "Hamısını seç",
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index ddcfa48f3b..5b8d69856f 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -7879,6 +7879,9 @@ export default {
omitAutoIncrement: "Omit AUTO_INCREMENT (for fresh-install scripts)",
includeData: "Table data (INSERT)",
includeObjects: "Views / Procedures / Functions / Sequences",
+ splitSqlOutput: "Split SQL export into a ZIP archive",
+ splitSqlPartMaxMb: "Maximum size per SQL part (MB)",
+ splitSqlOutputDescription: "Creates a ZIP archive containing numbered SQL parts and a manifest. Parts are split between complete SQL statements ({min}–{max} MB).",
databaseSelection: "Databases",
selectedDatabases: "{selected}/{total} selected",
selectAllDatabases: "Select all",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 9268e78984..5cad5c7f7d 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -7441,6 +7441,9 @@ export default withEnglishFallback({
omitAutoIncrement: "Omitir AUTO_INCREMENT (para scripts de instalación nueva)",
includeData: "Datos de tablas (INSERT)",
includeObjects: "Vistas / Procedimientos / Funciones / Secuencias",
+ splitSqlOutput: "Dividir la exportación SQL en un archivo ZIP",
+ splitSqlPartMaxMb: "Tamaño máximo por parte SQL (MB)",
+ splitSqlOutputDescription: "Crea un archivo ZIP con partes SQL numeradas y un manifiesto. Las partes se dividen entre sentencias SQL completas ({min}–{max} MB).",
databaseSelection: "Bases de datos",
selectedDatabases: "{selected}/{total} seleccionadas",
selectAllDatabases: "Seleccionar todo",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index ef15cb233b..b25aaa74c6 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -7442,6 +7442,9 @@ export default withEnglishFallback({
omitAutoIncrement: "Ometti AUTO_INCREMENT (per script di installazione pulita)",
includeData: "Dati tabella (INSERT)",
includeObjects: "Viste / Procedure / Funzioni / Sequenze",
+ splitSqlOutput: "Dividere l'esportazione SQL in un archivio ZIP",
+ splitSqlPartMaxMb: "Dimensione massima per parte SQL (MB)",
+ splitSqlOutputDescription: "Crea un archivio ZIP con parti SQL numerate e un manifest. Le parti vengono divise tra istruzioni SQL complete ({min}–{max} MB).",
databaseSelection: "Database",
selectedDatabases: "{selected}/{total} selezionati",
selectAllDatabases: "Seleziona tutti",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 39fcb4e61d..a9467478f0 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -7494,6 +7494,9 @@ export default withEnglishFallback({
omitAutoIncrement: "AUTO_INCREMENT を省略(新規インストール用スクリプト向け)",
includeData: "テーブルデータ (INSERT)",
includeObjects: "ビュー / プロシージャ / 関数 / シーケンス",
+ splitSqlOutput: "SQLエクスポートをZIPアーカイブに分割",
+ splitSqlPartMaxMb: "SQLパートあたりの最大サイズ(MB)",
+ splitSqlOutputDescription: "番号付きSQLパートとマニフェストを含むZIPアーカイブを作成します。パートは完全なSQL文の間で分割されます({min}~{max} MB)。",
databaseSelection: "データベース",
selectedDatabases: "{selected}/{total}件選択中",
selectAllDatabases: "すべて選択",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 48b838a4bf..3b54734777 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -7160,6 +7160,9 @@ export default withEnglishFallback({
omitAutoIncrement: "AUTO_INCREMENT 생략 (초기 설치 스크립트용)",
includeData: "테이블 데이터 (INSERT)",
includeObjects: "뷰 / 프로시저 / 함수 / 시퀀스",
+ splitSqlOutput: "SQL 내보내기를 ZIP 압축 파일로 분할",
+ splitSqlPartMaxMb: "SQL 파트당 최대 크기(MB)",
+ splitSqlOutputDescription: "번호가 지정된 SQL 파트와 매니페스트가 포함된 ZIP 압축 파일을 만듭니다. 파트는 완전한 SQL 문 사이에서 분할됩니다({min}–{max} MB).",
databaseSelection: "데이터베이스",
selectedDatabases: "{selected}/{total} 선택됨",
selectAllDatabases: "모두 선택",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index ddf2f705c6..ea696c63d9 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -7443,6 +7443,9 @@ export default withEnglishFallback({
omitAutoIncrement: "Omitir AUTO_INCREMENT (para scripts de instalação do zero)",
includeData: "Dados da tabela (INSERT)",
includeObjects: "Views / Procedures / Funções / Sequências",
+ splitSqlOutput: "Dividir exportação SQL em arquivo ZIP",
+ splitSqlPartMaxMb: "Tamanho máximo por parte SQL (MB)",
+ splitSqlOutputDescription: "Cria um arquivo ZIP com partes SQL numeradas e um manifesto. As partes são divididas entre instruções SQL completas ({min}–{max} MB).",
databaseSelection: "Bancos de dados",
selectedDatabases: "{selected}/{total} selecionados",
selectAllDatabases: "Selecionar todos",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index fff3b4a093..62facee42c 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -7733,6 +7733,9 @@ export default withEnglishFallback({
omitAutoIncrement: "AUTO_INCREMENT'i atla (sıfırdan kurulum betikleri için)",
includeData: "Tablo verisi (INSERT)",
includeObjects: "Görünümler / Yordamlar / İşlevler / Diziler",
+ splitSqlOutput: "SQL dışa aktarmayı ZIP arşivine böl",
+ splitSqlPartMaxMb: "SQL parçası başına maksimum boyut (MB)",
+ splitSqlOutputDescription: "Numaralı SQL parçaları ve bir bildirim içeren bir ZIP arşivi oluşturur. Parçalar tam SQL ifadeleri arasında bölünür ({min}–{max} MB).",
databaseSelection: "Veritabanları",
selectedDatabases: "{selected}/{total} seçildi",
selectAllDatabases: "Tümünü seç",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 38c45c046a..d83f36591a 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -7860,6 +7860,9 @@ export default withEnglishFallback({
omitAutoIncrement: "省略 AUTO_INCREMENT(用于全新安装的初始化脚本)",
includeData: "表数据 (INSERT)",
includeObjects: "视图 / 存储过程 / 函数 / 序列",
+ splitSqlOutput: "将 SQL 导出拆分为 ZIP 压缩包",
+ splitSqlPartMaxMb: "每个 SQL 分片的最大大小(MB)",
+ splitSqlOutputDescription: "创建包含编号 SQL 分片和清单的 ZIP 压缩包。分片仅在完整 SQL 语句之间拆分({min}–{max} MB)。",
databaseSelection: "选择数据库",
selectedDatabases: "已选择 {selected}/{total}",
selectAllDatabases: "全选",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index df1150af7a..e3da7633d6 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -6757,6 +6757,9 @@ export default withEnglishFallback({
omitAutoIncrement: "省略 AUTO_INCREMENT(用於全新安裝的初始化腳本)",
includeData: "資料表資料 (INSERT)",
includeObjects: "檢視 / 預存程序 / 函式 / 序列",
+ splitSqlOutput: "將 SQL 匯出分割為 ZIP 壓縮檔",
+ splitSqlPartMaxMb: "每個 SQL 分片的最大大小(MB)",
+ splitSqlOutputDescription: "建立包含編號 SQL 分片與資訊清單的 ZIP 壓縮檔。分片只會在完整 SQL 陳述式之間分割({min}–{max} MB)。",
databaseSelection: "選擇資料庫",
selectedDatabases: "已選擇 {selected}/{total}",
selectAllDatabases: "全選",
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 78fbecced4..d39f644776 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -4891,6 +4891,7 @@ export interface DatabaseExportRequest {
outputCompression?: "none" | "gzip";
snapshotSessionId?: string;
batchSize: number;
+ splitMaxMb?: number;
}
export interface DatabaseBackupSnapshot {
@@ -4941,6 +4942,7 @@ export interface TableExportRequest {
dateTimeFormat?: string;
numericColumnRightAlign?: boolean;
autoFilter?: boolean;
+ splitMaxMb?: number;
}
export interface TableCsvExportOptions {
diff --git a/apps/desktop/src/lib/export/sqlInsertMode.ts b/apps/desktop/src/lib/export/sqlInsertMode.ts
index ffc61099b9..a20a424d7b 100644
--- a/apps/desktop/src/lib/export/sqlInsertMode.ts
+++ b/apps/desktop/src/lib/export/sqlInsertMode.ts
@@ -4,17 +4,22 @@ import i18n from "@/i18n";
export type SqlInsertMode = "batch" | "single";
+export interface SqlExportOptions {
+ insertMode: SqlInsertMode;
+ splitMaxMb?: number;
+}
+
export const DEFAULT_SQL_INSERT_MODE: SqlInsertMode = "batch";
-export function showSqlInsertModeDialog(): Promise {
- if (typeof document === "undefined") return Promise.resolve(DEFAULT_SQL_INSERT_MODE);
+export function showSqlInsertModeDialog(options: { allowSplit?: boolean } = {}): Promise {
+ if (typeof document === "undefined") return Promise.resolve({ insertMode: 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) => {
+ const finish = (value: SqlExportOptions | null) => {
if (settled) return;
settled = true;
resolve(value);
@@ -23,7 +28,8 @@ export function showSqlInsertModeDialog(): Promise {
};
app = createApp(SqlInsertModeDialog, {
open: true,
- onConfirm: (insertMode: SqlInsertMode) => finish(insertMode),
+ allowSplit: options.allowSplit === true,
+ onConfirm: (options: SqlExportOptions) => finish(options),
onCancel: () => finish(null),
});
app.use(i18n);
diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs
index b4f290d066..0fcbae3ce8 100644
--- a/crates/dbx-core/src/database_export.rs
+++ b/crates/dbx-core/src/database_export.rs
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::future::Future;
-use std::io::{BufWriter, Write};
+use std::io::{BufRead, BufWriter, Write};
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::Duration;
@@ -126,11 +126,19 @@ pub struct DatabaseExportRequest {
#[serde(default)]
pub snapshot_session_id: Option,
pub batch_size: usize,
+ /// When set, the export is packaged as a `.zip` archive containing
+ /// multiple `part-N.sql` entries (plus a `manifest.json`), each capped
+ /// at this many megabytes, instead of one unbounded `.sql`/`.sql.gz`
+ /// file. Mutually exclusive with `output_compression` -- a zip archive
+ /// is its own compressed container.
+ #[serde(default)]
+ pub split_max_mb: Option,
}
enum DatabaseExportWriter {
Plain(BufWriter),
Gzip(Box>>),
+ SplitZip(Box),
}
impl Write for DatabaseExportWriter {
@@ -138,6 +146,7 @@ impl Write for DatabaseExportWriter {
match self {
Self::Plain(writer) => writer.write(buffer),
Self::Gzip(writer) => writer.write(buffer),
+ Self::SplitZip(writer) => writer.write(buffer),
}
}
@@ -145,17 +154,23 @@ impl Write for DatabaseExportWriter {
match self {
Self::Plain(writer) => writer.flush(),
Self::Gzip(writer) => writer.flush(),
+ Self::SplitZip(writer) => writer.flush(),
}
}
}
impl DatabaseExportWriter {
- fn finish(self) -> Result<(), String> {
+ fn finish(self, source_file_name: &str) -> Result<(), String> {
match self {
- Self::Plain(mut writer) => writer.flush(),
- Self::Gzip(writer) => writer.finish().and_then(|mut output| output.flush()),
+ Self::Plain(mut writer) => {
+ writer.flush().map_err(|error| format!("Failed to finalize export file: {error}"))
+ }
+ Self::Gzip(writer) => writer
+ .finish()
+ .and_then(|mut output| output.flush())
+ .map_err(|error| format!("Failed to finalize export file: {error}")),
+ Self::SplitZip(writer) => writer.finish(source_file_name),
}
- .map_err(|error| format!("Failed to finalize export file: {error}"))
}
}
@@ -2574,6 +2589,15 @@ fn export_destination_parent_dir(file_path: &str) -> Option<&std::path::Path> {
(!parent.as_os_str().is_empty()).then_some(parent)
}
+/// Name recorded inside `manifest.json` as the logical "source file" the
+/// split parts represent, e.g. `mydb.zip` -> `mydb.sql`. When splitting is
+/// disabled this is unused (the writer variant never calls it).
+fn export_source_file_name(file_path: &str) -> String {
+ let path = std::path::Path::new(file_path);
+ let stem = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or("export");
+ format!("{stem}.sql")
+}
+
async fn create_database_export_writer(
state: &Arc,
request: &DatabaseExportRequest,
@@ -2582,6 +2606,32 @@ async fn create_database_export_writer(
if let Some(parent) = export_destination_parent_dir(&request.file_path) {
expected_destination_identity = ensure_export_destination_dir(state, parent).await?;
}
+ if let Some(max_mb) = request.split_max_mb {
+ let zip_path = std::path::Path::new(&request.file_path);
+ let stem = zip_path.file_stem().and_then(|stem| stem.to_str()).unwrap_or("export");
+ let writer = crate::export_split_zip::SplitZipExportWriter::create_with_overwrite(
+ zip_path,
+ max_mb,
+ stem,
+ "sql",
+ request.prevent_overwrite,
+ )?;
+ let opened_destination_identity =
+ std::fs::File::open(&request.file_path).ok().as_ref().and_then(export_destination_identity_for_file);
+ if export_destination_identity_mismatch(
+ expected_destination_identity.as_ref(),
+ opened_destination_identity.as_ref(),
+ ) {
+ let _ = std::fs::remove_file(&request.file_path);
+ return Err(format!(
+ "Backup destination for {} changed while opening the output file -- the directory now \
+ resolves to a different filesystem than the one just verified. If a removable or network \
+ drive was disconnected and reconnected, retry the backup.",
+ request.file_path
+ ));
+ }
+ return Ok(DatabaseExportWriter::SplitZip(Box::new(writer)));
+ }
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
@@ -2636,6 +2686,21 @@ fn postgres_create_schema_sql(schema: &str) -> String {
format!("CREATE SCHEMA IF NOT EXISTS {};", quote_identifier(schema, &DatabaseType::Postgres))
}
+// Copy one line at a time so a `SplitZipExportWriter` can only rotate between
+// complete SQL statements; `std::io::copy` would feed it arbitrary 8KB chunks.
+fn combine_schema_sql_export(source: &mut dyn BufRead, destination: &mut W) -> std::io::Result<()> {
+ let mut line = Vec::new();
+ loop {
+ line.clear();
+ let bytes_read = source.read_until(b'\n', &mut line)?;
+ if bytes_read == 0 {
+ break;
+ }
+ destination.write_all(&line)?;
+ }
+ Ok(())
+}
+
async fn export_postgres_all_schemas_sql_core(
state: &Arc,
request: &DatabaseExportRequest,
@@ -2670,6 +2735,7 @@ async fn export_postgres_all_schemas_sql_core(
schema_request.schema = schema_name.clone();
schema_request.file_path = temp_dir.path().join(format!("schema-{schema_index}.sql")).display().to_string();
schema_request.output_compression = DatabaseExportOutputCompression::None;
+ schema_request.split_max_mb = None;
let terminal = Arc::new(std::sync::Mutex::new(None::));
let terminal_for_callback = terminal.clone();
@@ -2730,10 +2796,11 @@ async fn export_postgres_all_schemas_sql_core(
let mut source = std::io::BufReader::new(
std::fs::File::open(path).map_err(|e| format!("Failed to read temporary schema export: {e}"))?,
);
- std::io::copy(&mut source, &mut file).map_err(|e| format!("Failed to combine schema export: {e}"))?;
+ combine_schema_sql_export(&mut source, &mut file)
+ .map_err(|e| format!("Failed to combine schema export: {e}"))?;
writeln!(file).map_err(|e| format!("Failed to write file: {e}"))?;
}
- file.finish()?;
+ file.finish(&export_source_file_name(&request.file_path))?;
on_progress(ExportProgress {
export_id: request.export_id.clone(),
current_object: request.database.clone(),
@@ -3695,7 +3762,7 @@ async fn export_database_sql_core_inner(
writeln!(file, "SET FOREIGN_KEY_CHECKS = 1;").map_err(|e| format!("Failed to write file: {e}"))?;
}
- file.finish()?;
+ file.finish(&export_source_file_name(&request.file_path))?;
// Emit Done progress
on_progress(ExportProgress {
@@ -3758,7 +3825,7 @@ fn build_database_export_object_source_sql(
#[cfg(test)]
mod tests {
use super::{
- await_export_operation, await_export_stream_operation, clear_export_cancelled,
+ await_export_operation, await_export_stream_operation, clear_export_cancelled, combine_schema_sql_export,
concurrent_metadata_prefetch_allowed, database_export_metadata_prefetch_concurrency,
emit_database_export_cancelled, postgres_create_schema_sql, postgres_export_schema_names, set_export_cancelled,
snapshot_batch_cancelled, ExportStatus, EXPORT_CANCELLED_ERROR,
@@ -3978,6 +4045,7 @@ mod tests {
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
}
}
@@ -3991,7 +4059,7 @@ mod tests {
flate2::Compression::default(),
)));
writer.write_all(b"SELECT 1;\n").unwrap();
- writer.finish().unwrap();
+ writer.finish("backup.sql").unwrap();
let mut output = String::new();
flate2::read::GzDecoder::new(std::fs::File::open(path).unwrap()).read_to_string(&mut output).unwrap();
@@ -5471,6 +5539,136 @@ mod tests {
);
}
+ #[test]
+ fn split_zip_export_writer_splits_across_insert_batches_into_valid_sql() {
+ let directory = tempfile::tempdir().unwrap();
+ let zip_path = directory.path().join("orders.zip");
+ let mut writer = crate::export_split_zip::SplitZipExportWriter::create(
+ &zip_path,
+ crate::export_split_zip::MIN_SPLIT_PART_MAX_MB,
+ "orders",
+ "sql",
+ )
+ .unwrap();
+
+ // Each call is one full INSERT batch statement, exactly like the real
+ // write_database_export_rows call sites -- the writer must only cut
+ // between these calls, never inside one.
+ let long_value = "x".repeat(200_000);
+ for row_index in 0..20 {
+ write_database_export_rows(
+ &mut writer,
+ &[vec![json!(row_index), json!(long_value.clone())]],
+ &["id".to_string(), "payload".to_string()],
+ &[Some("bigint".to_string()), Some("text".to_string())],
+ &[None, None],
+ "orders",
+ "shop",
+ &DatabaseType::Postgres,
+ )
+ .unwrap();
+ }
+ writer.finish("orders.sql").unwrap();
+
+ let file = std::fs::File::open(&zip_path).unwrap();
+ let mut archive = zip::ZipArchive::new(file).unwrap();
+ let mut sql_parts = Vec::new();
+ for index in 0..archive.len() {
+ let mut entry = archive.by_index(index).unwrap();
+ if !entry.name().ends_with(".sql") {
+ continue;
+ }
+ let mut contents = String::new();
+ entry.read_to_string(&mut contents).unwrap();
+ sql_parts.push((entry.name().to_string(), contents));
+ }
+ sql_parts.sort_by(|a, b| a.0.cmp(&b.0));
+
+ assert!(
+ sql_parts.len() > 1,
+ "expected the large export to be split into multiple parts, got {}",
+ sql_parts.len()
+ );
+ for (name, contents) in &sql_parts {
+ assert!(!contents.is_empty(), "{name} must not be empty");
+ // Every non-blank line must be a syntactically complete INSERT
+ // statement -- proof that no cut landed inside one.
+ for line in contents.lines().filter(|line| !line.trim().is_empty()) {
+ assert!(
+ line.trim_start().starts_with("INSERT INTO") && line.trim_end().ends_with(';'),
+ "{name} has a malformed line from a mid-statement cut: {line}"
+ );
+ }
+ }
+ // Reassembling every part in order must reproduce all 20 rows.
+ let combined: String = sql_parts.iter().map(|(_, contents)| contents.as_str()).collect();
+ assert_eq!(combined.matches("INSERT INTO").count(), 20);
+ }
+
+ #[test]
+ fn all_schemas_combine_keeps_split_part_boundaries_statement_safe() {
+ // Mirror of the per-schema temporary files that
+ // `export_postgres_all_schemas_sql_core` combines: whole SQL
+ // statements, one per line, each newline-terminated.
+ let directory = tempfile::tempdir().unwrap();
+ let schema_path = directory.path().join("schema-0.sql");
+ let long_value = "x".repeat(200_000);
+ let mut schema_sql = String::new();
+ for row_index in 0..20 {
+ schema_sql.push_str(&format!("INSERT INTO orders VALUES ({row_index}, '{long_value}');\n"));
+ }
+ std::fs::write(&schema_path, &schema_sql).unwrap();
+
+ let zip_path = directory.path().join("combined.zip");
+ let mut writer = crate::export_split_zip::SplitZipExportWriter::create(
+ &zip_path,
+ crate::export_split_zip::MIN_SPLIT_PART_MAX_MB,
+ "combined",
+ "sql",
+ )
+ .unwrap();
+ let mut source = std::io::BufReader::new(std::fs::File::open(&schema_path).unwrap());
+ combine_schema_sql_export(&mut source, &mut writer).unwrap();
+ writer.finish("combined.sql").unwrap();
+
+ let file = std::fs::File::open(&zip_path).unwrap();
+ let mut archive = zip::ZipArchive::new(file).unwrap();
+ let mut sql_parts = Vec::new();
+ for index in 0..archive.len() {
+ let mut entry = archive.by_index(index).unwrap();
+ if !entry.name().ends_with(".sql") {
+ continue;
+ }
+ let mut contents = String::new();
+ entry.read_to_string(&mut contents).unwrap();
+ sql_parts.push((entry.name().to_string(), contents));
+ }
+ sql_parts.sort_by(|a, b| a.0.cmp(&b.0));
+
+ assert!(
+ sql_parts.len() > 1,
+ "expected the combined export to be split into multiple parts, got {}",
+ sql_parts.len()
+ );
+ for (name, contents) in &sql_parts {
+ assert!(!contents.is_empty(), "{name} must not be empty");
+ // Every non-blank line must be a complete statement -- proof that
+ // the copy never cut inside one.
+ for line in contents.lines().filter(|line| !line.trim().is_empty()) {
+ assert!(
+ line.trim_start().starts_with("INSERT INTO") && line.trim_end().ends_with(';'),
+ "{name} has a malformed line from a mid-statement cut"
+ );
+ }
+ }
+ // Reassembling the parts in order must reproduce the temporary file
+ // byte for byte: the line-by-line copy adds, drops, and alters
+ // nothing, including the trailing newline.
+ let combined: String = sql_parts.iter().map(|(_, contents)| contents.as_str()).collect();
+ assert_eq!(combined, schema_sql);
+ assert_eq!(combined.matches("INSERT INTO").count(), 20);
+ }
+
#[test]
fn dameng_identity_export_inserts_enable_identity_insert() {
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
diff --git a/crates/dbx-core/src/export_split_zip.rs b/crates/dbx-core/src/export_split_zip.rs
new file mode 100644
index 0000000000..15626b083c
--- /dev/null
+++ b/crates/dbx-core/src/export_split_zip.rs
@@ -0,0 +1,281 @@
+//! Shared support for splitting a large generated SQL export into multiple
+//! parts packaged inside a single `.zip` archive, instead of one unbounded
+//! `.sql` file. Used by both the whole-database export (`database_export.rs`)
+//! and the single-table SQL export (`table_export.rs`).
+//!
+//! # Cut boundary
+//! The writer never splits mid-write: it only checks the size threshold
+//! *before* starting a new `write()` call and, if exceeded, rotates to the
+//! next zip entry first. Every caller in this codebase writes one complete
+//! SQL statement (or comment line) per `write`/`writeln!` call, so a part
+//! boundary can land between two INSERT batches of the same table but never
+//! inside a single statement -- every part is independently valid, replayable
+//! SQL.
+
+use std::io::Write;
+
+use serde::Serialize;
+use zip::write::SimpleFileOptions;
+use zip::{CompressionMethod, ZipWriter};
+
+/// Default part size when a caller enables splitting without specifying a
+/// size (should not normally happen -- the frontend always sends an explicit
+/// value -- but keeps the backend safe against malformed requests).
+pub const DEFAULT_SPLIT_PART_MAX_MB: u32 = 100;
+pub const MIN_SPLIT_PART_MAX_MB: u32 = 1;
+pub const MAX_SPLIT_PART_MAX_MB: u32 = 4096;
+
+/// Clamp a user-supplied "max MB per part" value to a sane range so a
+/// malformed or hostile request cannot create an unbounded number of zip
+/// entries (too-small) or a single-part export that defeats the point
+/// (too-large is still allowed up to a generous ceiling).
+pub fn clamp_split_part_max_mb(value: u32) -> u32 {
+ value.clamp(MIN_SPLIT_PART_MAX_MB, MAX_SPLIT_PART_MAX_MB)
+}
+
+fn split_part_max_bytes(max_mb: u32) -> u64 {
+ u64::from(clamp_split_part_max_mb(max_mb)) * 1024 * 1024
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SplitExportManifest<'a> {
+ format: &'static str,
+ generated_at: String,
+ source_file_name: &'a str,
+ part_max_bytes: u64,
+ total_parts: u32,
+ parts: &'a [String],
+}
+
+/// Writes SQL export content across multiple `part-N.sql` entries inside a
+/// single `.zip` archive, rotating to a new entry once the current one
+/// reaches `part_max_bytes`. Call [`SplitZipExportWriter::finish`] to write
+/// the trailing `manifest.json` entry and flush the archive to disk.
+pub struct SplitZipExportWriter {
+ zip: ZipWriter,
+ options: SimpleFileOptions,
+ part_max_bytes: u64,
+ current_part_bytes: u64,
+ part_index: u32,
+ part_stem: String,
+ part_extension: String,
+ part_names: Vec,
+}
+
+impl SplitZipExportWriter {
+ /// `part_stem`/`part_extension` name each entry as `{stem}-part-{n}.{extension}`
+ /// (e.g. `mydb-part-1.sql`).
+ pub fn create(
+ zip_path: &std::path::Path,
+ max_mb: u32,
+ part_stem: &str,
+ part_extension: &str,
+ ) -> Result {
+ Self::create_with_overwrite(zip_path, max_mb, part_stem, part_extension, false)
+ }
+
+ pub fn create_with_overwrite(
+ zip_path: &std::path::Path,
+ max_mb: u32,
+ part_stem: &str,
+ part_extension: &str,
+ prevent_overwrite: bool,
+ ) -> Result {
+ let file = std::fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(!prevent_overwrite)
+ .create_new(prevent_overwrite)
+ .open(zip_path)
+ .map_err(|error| {
+ if prevent_overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
+ format!("Backup file already exists: {}", zip_path.display())
+ } else {
+ format!("Failed to create zip file: {error}")
+ }
+ })?;
+ let zip = ZipWriter::new(file);
+ let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
+ let mut writer = Self {
+ zip,
+ options,
+ part_max_bytes: split_part_max_bytes(max_mb),
+ current_part_bytes: 0,
+ part_index: 0,
+ part_stem: sanitize_zip_entry_component(part_stem),
+ part_extension: sanitize_zip_entry_component(part_extension),
+ part_names: Vec::new(),
+ };
+ writer.start_next_part()?;
+ Ok(writer)
+ }
+
+ fn start_next_part(&mut self) -> Result<(), String> {
+ self.part_index += 1;
+ let name = format!("{}-part-{}.{}", self.part_stem, self.part_index, self.part_extension);
+ self.zip
+ .start_file(&name, self.options)
+ .map_err(|error| format!("Failed to start zip entry {name}: {error}"))?;
+ self.part_names.push(name);
+ self.current_part_bytes = 0;
+ Ok(())
+ }
+
+ /// Writes the trailing `manifest.json` entry (listing every part in
+ /// order) and finalizes the zip archive. Consumes the writer because the
+ /// underlying `zip::ZipWriter::finish` does.
+ pub fn finish(mut self, source_file_name: &str) -> Result<(), String> {
+ let manifest = SplitExportManifest {
+ format: "dbx-sql-export-parts-v1",
+ generated_at: chrono::Local::now().to_rfc3339(),
+ source_file_name,
+ part_max_bytes: self.part_max_bytes,
+ total_parts: self.part_index,
+ parts: &self.part_names,
+ };
+ let manifest_json = serde_json::to_vec_pretty(&manifest)
+ .map_err(|error| format!("Failed to encode export manifest: {error}"))?;
+ let stored_options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
+ self.zip
+ .start_file("manifest.json", stored_options)
+ .map_err(|error| format!("Failed to start manifest entry: {error}"))?;
+ self.zip.write_all(&manifest_json).map_err(|error| format!("Failed to write export manifest: {error}"))?;
+ let mut file = self.zip.finish().map_err(|error| format!("Failed to finalize zip archive: {error}"))?;
+ file.flush().map_err(|error| format!("Failed to flush zip archive: {error}"))
+ }
+}
+
+impl Write for SplitZipExportWriter {
+ fn write(&mut self, buffer: &[u8]) -> std::io::Result {
+ if self.current_part_bytes >= self.part_max_bytes {
+ self.start_next_part().map_err(std::io::Error::other)?;
+ }
+ let written = self.zip.write(buffer)?;
+ self.current_part_bytes += written as u64;
+ Ok(written)
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.zip.flush()
+ }
+}
+
+/// Zip entry names must not contain path separators or other characters that
+/// could be misread as a directory traversal; export file stems come from
+/// user-controlled database/table names, so sanitize defensively.
+fn sanitize_zip_entry_component(value: &str) -> String {
+ let sanitized: String = value
+ .chars()
+ .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
+ .collect();
+ let trimmed = sanitized.trim_matches('_').to_string();
+ if trimmed.is_empty() {
+ "export".to_string()
+ } else {
+ trimmed
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Read;
+
+ fn read_zip_entries(path: &std::path::Path) -> Vec<(String, Vec)> {
+ let file = std::fs::File::open(path).unwrap();
+ let mut archive = zip::ZipArchive::new(file).unwrap();
+ let mut entries = Vec::new();
+ for index in 0..archive.len() {
+ let mut entry = archive.by_index(index).unwrap();
+ let mut contents = Vec::new();
+ entry.read_to_end(&mut contents).unwrap();
+ entries.push((entry.name().to_string(), contents));
+ }
+ entries
+ }
+
+ #[test]
+ fn clamps_split_part_max_mb_to_a_sane_range() {
+ assert_eq!(clamp_split_part_max_mb(0), MIN_SPLIT_PART_MAX_MB);
+ assert_eq!(clamp_split_part_max_mb(50), 50);
+ assert_eq!(clamp_split_part_max_mb(u32::MAX), MAX_SPLIT_PART_MAX_MB);
+ }
+
+ #[test]
+ fn rotates_to_a_new_part_once_the_threshold_is_reached() {
+ let dir = std::env::temp_dir().join(format!("dbx-split-zip-test-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let zip_path = dir.join("export.zip");
+
+ {
+ // 1 MB minimum threshold; each write is far smaller so every
+ // write should land in the very first part with no rotation.
+ let mut writer = SplitZipExportWriter::create(&zip_path, 1, "mydb", "sql").unwrap();
+ for index in 0..5 {
+ writeln!(writer, "INSERT INTO t VALUES ({index});").unwrap();
+ }
+ writer.finish("mydb.sql").unwrap();
+ }
+
+ let entries = read_zip_entries(&zip_path);
+ let sql_entries: Vec<_> = entries.iter().filter(|(name, _)| name.ends_with(".sql")).collect();
+ assert_eq!(sql_entries.len(), 1);
+ assert_eq!(sql_entries[0].0, "mydb-part-1.sql");
+ let manifest_entry = entries.iter().find(|(name, _)| name == "manifest.json").unwrap();
+ let manifest: serde_json::Value = serde_json::from_slice(&manifest_entry.1).unwrap();
+ assert_eq!(manifest["totalParts"], 1);
+ assert_eq!(manifest["parts"].as_array().unwrap().len(), 1);
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn splits_into_multiple_parts_and_every_part_is_valid_statements() {
+ let dir = std::env::temp_dir().join(format!("dbx-split-zip-test-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let zip_path = dir.join("export.zip");
+ // Force a rotation by using a threshold smaller than a single write.
+ let long_statement = format!("INSERT INTO t VALUES ('{}');", "x".repeat(200));
+
+ {
+ let mut writer = SplitZipExportWriter::create(&zip_path, MIN_SPLIT_PART_MAX_MB, "mydb", "sql").unwrap();
+ // Each write() call is one full statement; the threshold check
+ // happens before the call, so a small per-write payload combined
+ // with a below-1MB threshold still needs enough total bytes to
+ // exceed 1MB and trigger a second part. Use the internal minimum
+ // by writing repeatedly.
+ for index in 0..6000 {
+ writeln!(writer, "{long_statement} -- row {index}").unwrap();
+ }
+ writer.finish("mydb.sql").unwrap();
+ }
+
+ let entries = read_zip_entries(&zip_path);
+ let mut sql_entries: Vec<_> = entries.iter().filter(|(name, _)| name.ends_with(".sql")).collect();
+ sql_entries.sort_by(|a, b| a.0.cmp(&b.0));
+ assert!(sql_entries.len() >= 2, "expected at least 2 parts, got {}", sql_entries.len());
+
+ for (name, contents) in &sql_entries {
+ let text = String::from_utf8(contents.clone()).unwrap();
+ assert!(!text.is_empty(), "{name} must not be empty");
+ for line in text.lines().filter(|line| !line.trim().is_empty()) {
+ assert!(line.contains(';'), "{name} contains a line that is not a complete statement: {line}");
+ }
+ }
+
+ let manifest_entry = entries.iter().find(|(name, _)| name == "manifest.json").unwrap();
+ let manifest: serde_json::Value = serde_json::from_slice(&manifest_entry.1).unwrap();
+ assert_eq!(manifest["totalParts"].as_u64().unwrap() as usize, sql_entries.len());
+ assert_eq!(manifest["parts"].as_array().unwrap().len(), sql_entries.len());
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn sanitizes_zip_entry_stem_from_untrusted_names() {
+ assert_eq!(sanitize_zip_entry_component("../../etc/passwd"), ".._.._etc_passwd");
+ assert_eq!(sanitize_zip_entry_component(""), "export");
+ assert_eq!(sanitize_zip_entry_component("my db (prod)"), "my_db__prod");
+ }
+}
diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs
index 570729f798..f222c45a22 100644
--- a/crates/dbx-core/src/lib.rs
+++ b/crates/dbx-core/src/lib.rs
@@ -48,6 +48,7 @@ pub mod docs;
pub mod document_ops;
pub mod driver_runtime;
pub mod export_runtime;
+pub mod export_split_zip;
pub mod external;
pub mod hbase_ops;
pub mod history;
diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs
index 8dde1cd0ee..4cf5a9181e 100644
--- a/crates/dbx-core/src/table_export.rs
+++ b/crates/dbx-core/src/table_export.rs
@@ -83,6 +83,12 @@ pub struct TableExportRequest {
pub column_comments: Option>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_filter: Option,
+ /// SQL format only: when set, the export is packaged as a `.zip` archive
+ /// containing multiple `part-N.sql` entries (plus a `manifest.json`)
+ /// capped at this many megabytes each, instead of one unbounded `.sql`
+ /// file. Ignored for every other format.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub split_max_mb: Option,
}
#[derive(Debug, Clone, Serialize)]
@@ -975,6 +981,53 @@ async fn stream_native_table_rows(
}
}
+#[allow(clippy::too_many_arguments)]
+/// Output writer for the single-table SQL export. `Plain` is the historical
+/// unbounded `.sql` file; `SplitZip` packages the SQL into multiple
+/// `part-N.sql` entries inside a `.zip` when `TableExportRequest::split_max_mb`
+/// is set.
+enum TableExportSqlWriter {
+ Plain(BufWriter),
+ SplitZip(Box),
+}
+
+impl Write for TableExportSqlWriter {
+ fn write(&mut self, buffer: &[u8]) -> std::io::Result {
+ match self {
+ Self::Plain(writer) => writer.write(buffer),
+ Self::SplitZip(writer) => writer.write(buffer),
+ }
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ match self {
+ Self::Plain(writer) => writer.flush(),
+ Self::SplitZip(writer) => writer.flush(),
+ }
+ }
+}
+
+impl TableExportSqlWriter {
+ fn finish(self, source_file_name: &str) -> Result<(), String> {
+ match self {
+ Self::Plain(mut writer) => writer.flush().map_err(|error| format!("Failed to flush export file: {error}")),
+ Self::SplitZip(writer) => writer.finish(source_file_name),
+ }
+ }
+}
+
+fn create_table_export_sql_writer(request: &TableExportRequest) -> Result {
+ if let Some(max_mb) = request.split_max_mb {
+ let zip_path = std::path::Path::new(&request.file_path);
+ let stem = zip_path.file_stem().and_then(|stem| stem.to_str()).unwrap_or(&request.table_name);
+ let writer = crate::export_split_zip::SplitZipExportWriter::create(zip_path, max_mb, stem, "sql")?;
+ return Ok(TableExportSqlWriter::SplitZip(Box::new(writer)));
+ }
+ Ok(TableExportSqlWriter::Plain(BufWriter::new(
+ std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?,
+ )))
+}
+
#[allow(clippy::too_many_arguments)]
async fn try_export_native_table_stream(
state: &AppState,
@@ -1254,13 +1307,11 @@ async fn try_export_native_table_stream(
result
}
"sql" => {
- let mut file = BufWriter::new(
- std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?,
- );
+ let mut file = create_table_export_sql_writer(request)?;
let mut pending_rows: Vec> = Vec::new();
let mut wrote_statements = false;
let mut flush_pending =
- |file: &mut BufWriter, pending_rows: &mut Vec>| -> Result<(), String> {
+ |file: &mut TableExportSqlWriter, pending_rows: &mut Vec>| -> Result<(), String> {
if pending_rows.is_empty() {
return Ok(());
}
@@ -1321,7 +1372,7 @@ async fn try_export_native_table_stream(
if wrote_statements {
file.write_all(b"\n").map_err(|e| format!("Failed to write SQL: {e}"))?;
}
- file.flush().map_err(|e| format!("Failed to flush export file: {e}"))?;
+ file.finish(&format!("{}.sql", request.table_name))?;
}
result
}
@@ -1562,8 +1613,13 @@ async fn export_table_data_core_inner(
}
// 8. Create output file
- let file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?;
- let mut file = BufWriter::new(file);
+ let mut file = if request.format.eq_ignore_ascii_case("sql") {
+ create_table_export_sql_writer(request)?
+ } else {
+ TableExportSqlWriter::Plain(BufWriter::new(
+ std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?,
+ ))
+ };
let mut text_buffer = String::new();
let mut rows_exported: u64 = 0;
@@ -2154,7 +2210,7 @@ async fn export_table_data_core_inner(
}
close_table_export_cursor_if_open(state, &pool_key, request, &mut cursor_session).await;
- file.flush().map_err(|e| format!("Failed to flush export file: {e}"))?;
+ file.finish(&format!("{}.sql", request.table_name))?;
// 8. Emit Done progress
on_progress(TableExportProgress {
@@ -2304,6 +2360,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
ExternalDriverExportFixture { state, request, calls, output, dir }
@@ -2466,6 +2523,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
export_table_data_core(&state, &request, |_| {}).await.unwrap();
@@ -2631,6 +2689,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let context = table_export_sql_context(DatabaseType::Iotdb, None, request.schema.as_deref());
let columns = vec!["Time".to_string(), "root.test.device2.temperature".to_string()];
@@ -2689,6 +2748,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let context = table_export_sql_context(DatabaseType::Iotdb, None, request.schema.as_deref());
let columns = vec!["tImE".to_string(), "temperature".to_string()];
@@ -2725,6 +2785,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let context = table_export_sql_context(DatabaseType::Iotdb, None, request.schema.as_deref());
let error = table_export_query_columns(&request, &context, &["TIME".to_string()]).unwrap_err();
@@ -2756,6 +2817,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let context = table_export_sql_context(DatabaseType::Iotdb, None, request.schema.as_deref());
let columns = vec![
@@ -2795,6 +2857,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let columns = vec!["Time".to_string(), "value".to_string()];
@@ -2846,6 +2909,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let context = table_export_sql_context(DatabaseType::Oracle, None, request.schema.as_deref());
@@ -2891,6 +2955,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let columns = vec!["id".to_string(), "payload".to_string()];
let primary_keys = vec!["id".to_string()];
@@ -2964,6 +3029,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let columns = vec!["id".to_string(), "DisplayName".to_string()];
let primary_keys = vec!["id".to_string()];
@@ -3019,6 +3085,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let columns = vec!["id".to_string(), "geom".to_string(), "name".to_string()];
let column_types = vec![Some("int".to_string()), Some("geometry".to_string()), Some("varchar".to_string())];
@@ -3076,6 +3143,7 @@ mod tests {
numeric_column_right_align: false,
column_comments: None,
auto_filter: None,
+ split_max_mb: None,
};
let context = table_export_sql_context(DatabaseType::Oracle, None, request.schema.as_deref());
let sql = table_cursor_sql(&request, &context, &columns, &[], &primary_keys);
@@ -3200,6 +3268,187 @@ mod tests {
cleanup_external_driver_export_fixture(fixture);
}
+ #[cfg(unix)]
+ #[test]
+ fn external_driver_sql_export_without_split_writes_plain_sql() {
+ // The fallback export future is too large for the default test-thread
+ // stack in debug builds; mirror the dedicated-thread pattern used by
+ // the other fallback-path tests.
+ let handle = std::thread::Builder::new()
+ .name("table-export-plain-sql".to_string())
+ .stack_size(8 * 1024 * 1024)
+ .spawn(|| {
+ tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("build table export plain SQL test runtime")
+ .block_on(run_external_driver_sql_export_without_split_writes_plain_sql());
+ })
+ .expect("spawn table export plain SQL test thread");
+ if let Err(panic) = handle.join() {
+ std::panic::resume_unwind(panic);
+ }
+ }
+
+ #[cfg(unix)]
+ async fn run_external_driver_sql_export_without_split_writes_plain_sql() {
+ let fixture = external_driver_export_fixture(
+ r#" case "$line" in
+ *'"method":"executeQueryPage"'*)
+ echo executeQueryPage >> "$CALLS"
+ printf '{"id":%s,"result":{"columns":["id","name"],"rows":[[1,"Ada"],[2,"Grace"]],"affected_rows":0,"execution_time_ms":1,"session_id":null,"has_more":false}}\n' "$id"
+ ;;
+ esac"#,
+ 2,
+ None,
+ true,
+ )
+ .await;
+ let output = fixture.dir.join("export.sql");
+ let mut request = fixture.request.clone();
+ request.format = "sql".to_string();
+ request.file_path = output.to_string_lossy().into_owned();
+
+ let terminal = Arc::new(std::sync::Mutex::new(None::));
+ let captured = terminal.clone();
+ export_table_data_core(&fixture.state, &request, move |event| {
+ *captured.lock().unwrap() = Some(event.status);
+ })
+ .await
+ .expect("plain JDBC SQL export should succeed");
+
+ let sql = std::fs::read_to_string(&output).unwrap();
+ // Default batch insert mode emits one multi-row INSERT statement.
+ assert_eq!(sql.matches("INSERT INTO").count(), 1, "both rows should be exported: {sql}");
+ assert!(sql.contains("Ada"));
+ assert!(sql.contains("Grace"));
+ assert!(sql.ends_with(";\n"));
+ // Without `split_max_mb` the fallback arm must keep producing a plain
+ // SQL file, not a zip archive.
+ assert!(!sql.starts_with("PK"));
+ assert_eq!(std::fs::read_to_string(&fixture.calls).unwrap(), "executeQueryPage\n");
+ assert!(matches!(*terminal.lock().unwrap(), Some(ExportStatus::Done)));
+
+ cleanup_external_driver_export_fixture(fixture);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn external_driver_sql_export_honors_split_max_mb_into_zip_parts() {
+ // See external_driver_sql_export_without_split_writes_plain_sql: the
+ // fallback export future needs a larger-than-default test stack.
+ let handle = std::thread::Builder::new()
+ .name("table-export-split-sql".to_string())
+ .stack_size(8 * 1024 * 1024)
+ .spawn(|| {
+ tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("build table export split SQL test runtime")
+ .block_on(run_external_driver_sql_export_honors_split_max_mb_into_zip_parts());
+ })
+ .expect("spawn table export split SQL test thread");
+ if let Err(panic) = handle.join() {
+ std::panic::resume_unwind(panic);
+ }
+ }
+
+ #[cfg(unix)]
+ async fn run_external_driver_sql_export_honors_split_max_mb_into_zip_parts() {
+ // Two cursor pages: 200 wide rows (~1.2MB of INSERT statements) then a
+ // short 100-row page. The first page alone must exceed the 1MB part
+ // threshold, forcing the second page into a new zip part, and every
+ // part must still contain only complete statements.
+ let fixture = external_driver_export_fixture(
+ r#"LONG=$(head -c 6000 /dev/zero | tr '\0' 'x')
+ROWS=''
+i=1
+while [ $i -le 100 ]; do ROWS="$ROWS,[$i,\"$LONG\"]"; i=$((i+1)); done
+ROWS=${ROWS#,}
+case "$line" in
+ *'"method":"executeQueryPage"'*)
+ echo executeQueryPage >> "$CALLS"
+ printf '{"id":%s,"result":{"columns":["id","name"],"rows":[%s,%s],"affected_rows":0,"execution_time_ms":1,"session_id":"cursor-1","has_more":true}}\n' "$id" "$ROWS" "$ROWS"
+ ;;
+ *'"method":"fetchQueryPage"'*)
+ echo fetchQueryPage >> "$CALLS"
+ printf '{"id":%s,"result":{"columns":["id","name"],"rows":[%s],"affected_rows":0,"execution_time_ms":1,"session_id":null,"has_more":false}}\n' "$id" "$ROWS"
+ ;;
+ *'"method":"closeQuerySession"'*)
+ echo closeQuerySession >> "$CALLS"
+ printf '{"id":%s,"result":{"ok":true}}\n' "$id"
+ ;;
+esac"#,
+ 200,
+ None,
+ true,
+ )
+ .await;
+ let zip_path = fixture.dir.join("export.zip");
+ let mut request = fixture.request.clone();
+ request.format = "sql".to_string();
+ request.file_path = zip_path.to_string_lossy().into_owned();
+ request.split_max_mb = Some(crate::export_split_zip::MIN_SPLIT_PART_MAX_MB);
+
+ let terminal = Arc::new(std::sync::Mutex::new(None::));
+ let captured = terminal.clone();
+ export_table_data_core(&fixture.state, &request, move |event| {
+ *captured.lock().unwrap() = Some(event.status);
+ })
+ .await
+ .expect("split JDBC SQL export should succeed");
+
+ // The final page reports `has_more: false` with no session id, so the
+ // cursor is closed implicitly and no closeQuerySession RPC is issued.
+ assert_eq!(std::fs::read_to_string(&fixture.calls).unwrap(), "executeQueryPage\nfetchQueryPage\n");
+ let file = std::fs::File::open(&zip_path).unwrap();
+ let mut archive = zip::ZipArchive::new(file).unwrap();
+ let mut sql_parts = Vec::new();
+ let mut manifest = None;
+ for index in 0..archive.len() {
+ let mut entry = archive.by_index(index).unwrap();
+ if entry.name() == "manifest.json" {
+ let mut contents = Vec::new();
+ entry.read_to_end(&mut contents).unwrap();
+ manifest = Some(serde_json::from_slice::(&contents).unwrap());
+ continue;
+ }
+ let mut contents = String::new();
+ entry.read_to_string(&mut contents).unwrap();
+ sql_parts.push((entry.name().to_string(), contents));
+ }
+ sql_parts.sort_by(|a, b| a.0.cmp(&b.0));
+
+ assert!(
+ sql_parts.len() >= 2,
+ "expected the fallback SQL export to be split into multiple parts, got {}",
+ sql_parts.len()
+ );
+ for (name, contents) in &sql_parts {
+ assert!(!contents.is_empty(), "{name} must not be empty");
+ // Every non-blank line must be a complete statement -- the part
+ // boundary may only land between the fallback arm's per-batch
+ // writes, never inside a statement.
+ for line in contents.lines().filter(|line| !line.trim().is_empty()) {
+ assert!(
+ line.trim_start().starts_with("INSERT INTO") && line.trim_end().ends_with(';'),
+ "{name} has a malformed line from a mid-statement cut"
+ );
+ }
+ }
+ // All 300 rows must be present across the parts: every row value is
+ // `(n, 'xxxx…')`, so one `, '` occurrence per row.
+ let combined: String = sql_parts.iter().map(|(_, contents)| contents.as_str()).collect();
+ assert_eq!(combined.matches(", '").count(), 300);
+
+ let manifest = manifest.expect("split export must contain a manifest");
+ assert_eq!(manifest["totalParts"].as_u64().unwrap() as usize, sql_parts.len());
+ assert_eq!(manifest["sourceFileName"], "EXPORT_SAMPLE.sql");
+ assert!(matches!(*terminal.lock().unwrap(), Some(ExportStatus::Done)));
+
+ cleanup_external_driver_export_fixture(fixture);
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn external_driver_table_export_does_not_repeat_legacy_one_shot_results() {
diff --git a/crates/dbx-core/tests/database_export_partition_ddl.rs b/crates/dbx-core/tests/database_export_partition_ddl.rs
index e19668f7f6..5e613f7c54 100644
--- a/crates/dbx-core/tests/database_export_partition_ddl.rs
+++ b/crates/dbx-core/tests/database_export_partition_ddl.rs
@@ -141,6 +141,7 @@ async fn run_database_export_of_partition_tree_has_no_duplicates_and_replays() {
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
export_database_sql_core(&state, &request, |_progress| {}).await.expect("export should succeed");
diff --git a/crates/dbx-core/tests/database_export_prefetch.rs b/crates/dbx-core/tests/database_export_prefetch.rs
index ceb3cb3006..d6a1e34697 100644
--- a/crates/dbx-core/tests/database_export_prefetch.rs
+++ b/crates/dbx-core/tests/database_export_prefetch.rs
@@ -80,6 +80,7 @@ async fn run_database_export_writes_structure_and_data_for_all_tables() {
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
export_database_sql_core(&state, &request, |_progress| {}).await.expect("export should succeed");
diff --git a/crates/dbx-core/tests/live_manual_transaction.rs b/crates/dbx-core/tests/live_manual_transaction.rs
index 51f4711d4e..e808ea3a0a 100644
--- a/crates/dbx-core/tests/live_manual_transaction.rs
+++ b/crates/dbx-core/tests/live_manual_transaction.rs
@@ -333,6 +333,7 @@ async fn live_postgres_backup_snapshot_exports_wide_jsonb_then_next_table_inner(
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 1000,
+ split_max_mb: None,
};
let terminal_rows = AtomicU64::new(u64::MAX);
let export_result = export_database_sql_core(&state, &request, |progress| {
@@ -447,6 +448,7 @@ async fn live_postgres_backup_snapshot_cancel_interrupts_pending_row_inner() {
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 1,
+ split_max_mb: None,
};
let rows_seen = Arc::new(AtomicU64::new(0));
let terminal_status = Arc::new(AtomicU64::new(0));
@@ -563,6 +565,7 @@ async fn live_mysql_database_backup_refreshes_an_idle_snapshot_before_export() {
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 100,
+ split_max_mb: None,
};
let export_result = export_database_sql_core(&state, &request, |_| {}).await;
let rollback_result = rollback_manual_transaction(&state, &snapshot.session_id).await;
diff --git a/crates/dbx-core/tests/live_mysql_database_export.rs b/crates/dbx-core/tests/live_mysql_database_export.rs
index 1aa6b24a1f..79e74da41f 100644
--- a/crates/dbx-core/tests/live_mysql_database_export.rs
+++ b/crates/dbx-core/tests/live_mysql_database_export.rs
@@ -82,6 +82,7 @@ async fn live_mysql_database_export_restores_dependent_views() {
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
let test_result = async {
export_database_sql_core(&state, &export_request, |_| {}).await?;
@@ -217,6 +218,7 @@ async fn run_live_mysql_database_export_handles_many_tables_including_empty_tabl
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
export_database_sql_core(&state, &request, |_| {}).await?;
@@ -311,6 +313,7 @@ async fn live_mysql_database_export_creates_missing_destination_directory() {
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
let result = export_database_sql_core(&state, &export_request, |_| {}).await;
@@ -377,6 +380,7 @@ async fn live_mysql_database_export_refuses_to_recreate_a_destination_that_disap
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
export_database_sql_core(&state, &export_request, |_| {}).await.expect("first export should succeed");
@@ -464,6 +468,7 @@ async fn live_mysql_database_export_refuses_a_destination_that_vanished_before_i
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
// The mount disappears before the scheduler ever runs this schedule for
diff --git a/crates/dbx-core/tests/live_mysql_export_table_order.rs b/crates/dbx-core/tests/live_mysql_export_table_order.rs
index 110eaebd4f..177b897185 100644
--- a/crates/dbx-core/tests/live_mysql_export_table_order.rs
+++ b/crates/dbx-core/tests/live_mysql_export_table_order.rs
@@ -105,6 +105,7 @@ async fn live_mysql_database_export_table_order_is_not_alphabetical_when_fk_reor
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
};
let test_result = async {
diff --git a/crates/dbx-core/tests/live_postgres_all_schema_export.rs b/crates/dbx-core/tests/live_postgres_all_schema_export.rs
index cf74248203..30f3982a46 100644
--- a/crates/dbx-core/tests/live_postgres_all_schema_export.rs
+++ b/crates/dbx-core/tests/live_postgres_all_schema_export.rs
@@ -114,6 +114,7 @@ async fn live_postgres_all_schema_export_restores_one_sql_file() {
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+ split_max_mb: None,
},
move |event| progress_sink.lock().expect("progress mutex poisoned").push(event),
)
diff --git a/crates/dbx-web/src/routes/database_export.rs b/crates/dbx-web/src/routes/database_export.rs
index b3009bc663..7830a1271a 100644
--- a/crates/dbx-web/src/routes/database_export.rs
+++ b/crates/dbx-web/src/routes/database_export.rs
@@ -44,7 +44,13 @@ pub async fn start_database_export(
// endpoint after the export completes.
let tmp_dir = state.data_dir.join("tmp");
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?;
- let extension = if req.output_compression == DatabaseExportOutputCompression::Gzip { "sql.gz" } else { "sql" };
+ let extension = if req.split_max_mb.is_some() {
+ "zip"
+ } else if req.output_compression == DatabaseExportOutputCompression::Gzip {
+ "sql.gz"
+ } else {
+ "sql"
+ };
let tmp_file = tmp_dir.join(format!("database_export_{export_id}.{extension}"));
let tmp_file_path = tmp_file.to_string_lossy().to_string();
req.file_path = tmp_file_path.clone();
@@ -170,8 +176,13 @@ pub async fn database_export_download(
// Clean up temp file
let _ = tokio::fs::remove_file(&export_file.file_path).await;
- let content_type =
- if export_file.format == "sql.gz" { "application/gzip" } else { "application/sql; charset=utf-8" };
+ let content_type = if export_file.format == "zip" {
+ "application/zip"
+ } else if export_file.format == "sql.gz" {
+ "application/gzip"
+ } else {
+ "application/sql; charset=utf-8"
+ };
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
diff --git a/crates/dbx-web/src/routes/table_export.rs b/crates/dbx-web/src/routes/table_export.rs
index 354bce206e..c9070e15ca 100644
--- a/crates/dbx-web/src/routes/table_export.rs
+++ b/crates/dbx-web/src/routes/table_export.rs
@@ -46,6 +46,7 @@ pub async fn start_table_export(
"xlsx" => "xlsx",
"json" => "json",
"markdown" | "md" => "md",
+ "sql" if req.split_max_mb.is_some() => "zip",
"sql" => "sql",
_ => return Err(AppError::from(format!("Unsupported export format: {}", req.format))),
};
@@ -59,7 +60,7 @@ pub async fn start_table_export(
.export_files
.write()
.await
- .insert(export_id.clone(), WebExportFile { file_path, download_filename, format: req.format.clone() });
+ .insert(export_id.clone(), WebExportFile { file_path, download_filename, format: ext.to_string() });
let tx = {
let mut channels = state.sse_channels.write().await;
@@ -152,6 +153,7 @@ pub async fn table_export_download(
"json" => "application/json; charset=utf-8",
"markdown" | "md" => "text/markdown; charset=utf-8",
"sql" => "application/sql; charset=utf-8",
+ "zip" => "application/zip",
format => return Err(AppError::from(format!("Unknown format: {format}"))),
};