feat(export): support column selection in SQL exports

Closes #9475
This commit is contained in:
t8y2
2026-09-27 22:37:50 +00:00
parent e9998e2806
commit 0a6ab8d360
28 changed files with 923 additions and 85 deletions
@@ -1,19 +1,29 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, 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 { SqlExportOptions, SqlInsertMode } from "@/lib/export/sqlInsertMode";
import type { SqlExportColumnSelection, SqlExportOptions, SqlInsertMode } from "@/lib/export/sqlInsertMode";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{ allowSplit?: boolean }>();
const props = defineProps<{ allowSplit?: boolean; columns?: SqlExportColumnSelection[] }>();
const selected = ref<SqlInsertMode>("batch");
const selectedColumnIndexes = ref(props.columns?.map((column) => column.sourceIndex) ?? []);
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 columnSelectionEnabled = computed(() => props.columns !== undefined);
const selectedColumnSet = computed(() => new Set(selectedColumnIndexes.value));
const selectedColumns = computed(() => props.columns?.filter((column) => selectedColumnSet.value.has(column.sourceIndex)) ?? []);
const canConfirm = computed(() => !columnSelectionEnabled.value || selectedColumns.value.length > 0);
const duplicateColumnNames = computed(() => {
const counts = new Map<string, number>();
for (const column of props.columns ?? []) counts.set(column.name, (counts.get(column.name) ?? 0) + 1);
return new Set([...counts].flatMap(([name, count]) => (count > 1 ? [name] : [])));
});
const emit = defineEmits<{
confirm: [options: SqlExportOptions];
@@ -27,14 +37,24 @@ function normalizedSplitSqlPartMaxMb(): number {
}
function onConfirm() {
if (!canConfirm.value) return;
outcomeEmitted = true;
open.value = false;
emit("confirm", {
insertMode: selected.value,
splitMaxMb: props.allowSplit && splitSqlOutput.value ? normalizedSplitSqlPartMaxMb() : undefined,
...(columnSelectionEnabled.value ? { selectedColumns: selectedColumns.value } : {}),
});
}
function selectAllColumns() {
selectedColumnIndexes.value = props.columns?.map((column) => column.sourceIndex) ?? [];
}
function clearColumns() {
selectedColumnIndexes.value = [];
}
function onCancel() {
if (outcomeEmitted) return;
outcomeEmitted = true;
@@ -69,6 +89,27 @@ function onOpenChange(value: boolean) {
<span class="mt-1 block text-xs text-muted-foreground">{{ t("grid.sqlInsertModeSingleDescription") }}</span>
</span>
</label>
<div v-if="columnSelectionEnabled" class="space-y-2 rounded-md border p-3" data-sql-export-columns>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium">{{ t("databaseExport.sqlColumnSelection") }}</p>
<p class="text-xs text-muted-foreground">{{ t("databaseExport.selectedColumns", { selected: selectedColumns.length, total: props.columns?.length ?? 0 }) }}</p>
</div>
<div class="flex gap-2">
<Button type="button" variant="ghost" size="sm" data-sql-export-select-all @click="selectAllColumns">{{ t("databaseExport.selectAllColumns") }}</Button>
<Button type="button" variant="ghost" size="sm" data-sql-export-clear @click="clearColumns">{{ t("databaseExport.clearColumns") }}</Button>
</div>
</div>
<div class="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
<label v-for="column in props.columns" :key="column.sourceIndex" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm hover:bg-accent/50">
<input v-model="selectedColumnIndexes" type="checkbox" :value="column.sourceIndex" class="h-4 w-4" :data-sql-export-column="column.sourceIndex" />
<span class="truncate"
>{{ column.name }}<span v-if="duplicateColumnNames.has(column.name)" class="text-muted-foreground"> #{{ column.nameOccurrence + 1 }}</span></span
>
</label>
</div>
<p v-if="!canConfirm" role="alert" class="text-xs text-destructive" data-sql-export-column-error>{{ t("databaseExport.selectAtLeastOneColumn") }}</p>
</div>
<div v-if="props.allowSplit" class="space-y-2 rounded-md border p-3">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input v-model="splitSqlOutput" type="checkbox" class="h-4 w-4" data-sql-split-output />
@@ -85,7 +126,7 @@ function onOpenChange(value: boolean) {
</div>
<DialogFooter>
<Button variant="outline" @click="onCancel">{{ t("common.cancel") }}</Button>
<Button data-sql-insert-mode-confirm @click="onConfirm">{{ t("common.confirm") }}</Button>
<Button :disabled="!canConfirm" data-sql-insert-mode-confirm @click="onConfirm">{{ t("common.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -3,6 +3,8 @@
import { createApp, h, nextTick, type App, defineComponent } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { sqlExportColumnChoices } from "@/lib/export/sqlExportColumns";
import type { SqlExportColumnSelection } from "@/lib/export/sqlInsertMode";
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
@@ -37,13 +39,13 @@ afterEach(() => {
i18n.global.locale.value = "en";
});
async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit = false) {
async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit = false, columns?: SqlExportColumnSelection[]) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(
defineComponent({
setup() {
return () => h(SqlInsertModeDialog, { open: true, allowSplit, onConfirm, onCancel });
return () => h(SqlInsertModeDialog, { open: true, allowSplit, columns, onConfirm, onCancel });
},
}),
);
@@ -54,6 +56,37 @@ async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit
}
describe("SqlInsertModeDialog", () => {
it("defaults to every column and allows deselecting one duplicate independently", async () => {
const onConfirm = vi.fn();
const choices = sqlExportColumnChoices(["id", "name", "id"]);
await mountDialog(onConfirm, () => {}, false, choices);
expect([...document.querySelectorAll<HTMLInputElement>("[data-sql-export-column]")].every((input) => input.checked)).toBe(true);
expect(document.body.textContent).toContain("#1");
expect(document.body.textContent).toContain("#2");
document.querySelector<HTMLInputElement>('[data-sql-export-column="0"]')?.click();
await nextTick();
document.querySelector<HTMLButtonElement>("[data-sql-insert-mode-confirm]")?.click();
expect(onConfirm).toHaveBeenCalledWith({ insertMode: "batch", splitMaxMb: undefined, selectedColumns: choices.slice(1) });
});
it("blocks an empty selection and restores every column with select all", async () => {
const onConfirm = vi.fn();
const choices = sqlExportColumnChoices(["id", "name"]);
await mountDialog(onConfirm, () => {}, true, choices);
document.querySelector<HTMLButtonElement>("[data-sql-export-clear]")?.click();
await nextTick();
const confirm = document.querySelector<HTMLButtonElement>("[data-sql-insert-mode-confirm]");
expect(confirm?.disabled).toBe(true);
expect(document.querySelector("[data-sql-export-column-error]")).not.toBeNull();
confirm?.click();
expect(onConfirm).not.toHaveBeenCalled();
document.querySelector<HTMLButtonElement>("[data-sql-export-select-all]")?.click();
await nextTick();
expect(confirm?.disabled).toBe(false);
confirm?.click();
expect(onConfirm).toHaveBeenCalledWith({ insertMode: "batch", splitMaxMb: undefined, selectedColumns: choices });
});
it("defaults to batch mode and emits the selected single-row mode", async () => {
const onConfirm = vi.fn();
await mountDialog(onConfirm);
@@ -0,0 +1,188 @@
import { computed, ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport";
import { useExportTracker } from "@/composables/useExportTracker";
import { sqlExportColumnChoices } from "@/lib/export/sqlExportColumns";
import { showSqlInsertModeDialog } from "@/lib/export/sqlInsertMode";
import { formatSqlInsert } from "@/lib/export/exportFormats";
import { saveTextFile } from "@/lib/export/saveTextFile";
import * as api from "@/lib/backend/api";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({ native: true, toast: vi.fn() }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.native }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/exports/result.sql") }));
vi.mock("@/lib/backend/api", () => ({ startQueryResultExport: vi.fn(), startTableExport: vi.fn(), cancelQueryResultExport: vi.fn(), cancelTableExport: vi.fn() }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useDataGridExtractor", () => ({ useDataGridExtractor: () => ({ copyWithExtractor: vi.fn(), previewWithExtractor: vi.fn(), canCopyWithExtractor: vi.fn() }) }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { exportBatchSize: 1000, globalDateTimeExportFormat: "", numericColumnRightAlign: true } }) }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/i18n", () => ({ default: { install() {} } }));
vi.mock("@/lib/export/sqlInsertMode", () => ({ showSqlInsertModeDialog: vi.fn() }));
vi.mock("@/lib/export/exportFormats", () => ({ formatSqlInsert: vi.fn().mockResolvedValue("INSERT INTO users VALUES ('Ada');"), formatTsv: vi.fn() }));
vi.mock("@/lib/export/saveTextFile", async (importOriginal) => ({ ...(await importOriginal<object>()), saveTextFile: vi.fn() }));
function createOptions(overrides: Partial<UseDataGridExportOptions> = {}): UseDataGridExportOptions {
const items = [
{ id: 7, sourceIndex: 0, data: [1, "Ada"], isNew: false, isDeleted: false, isDirtyCol: [], status: "" },
{ id: 8, sourceIndex: 1, data: [2, "Lin"], isNew: false, isDeleted: false, isDirtyCol: [], status: "" },
];
return {
columns: computed(() => ["id", "name"]),
displayItems: computed(() => items),
sql: computed(() => "SELECT id, name FROM users"),
tableMeta: computed(() => ({
tableName: "users",
primaryKeys: [],
columns: [
{ name: "id", data_type: "int" },
{ name: "name", data_type: "text" },
],
})),
databaseType: computed(() => "postgres"),
connectionId: computed(() => "conn"),
database: computed(() => "dbx"),
context: computed(() => "results"),
sourceColumns: computed(() => ["id", "name"]),
columnTypes: computed(() => ["int4", "text"]),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedCellMatrix: computed(() => null),
selectedRange: computed(() => null),
contextCell: ref(null),
contextSelectionIsSynthetic: ref(false),
getRowItem: (id) => items.find((item) => item.id === id),
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
queryResultExportRequest: vi.fn(async (request) => ({ ...request, connectionId: "conn", database: "dbx", databaseType: "postgres", sql: "SELECT id, name FROM users", queryBaseSql: "SELECT id, name FROM users", useAgentCursor: false, pageSize: 1000, keysetOptimizationEnabled: true })),
...overrides,
};
}
describe("SQL export column selection across entrypoints", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.native = true;
vi.mocked(showSqlInsertModeDialog).mockResolvedValue({ insertMode: "single", selectedColumns: [sqlExportColumnChoices(["id", "name"])[1]!] });
const done = { exportId: "export", tableName: "users", rowsExported: 2, totalRows: 2, status: "Done" as const };
vi.mocked(api.startQueryResultExport).mockResolvedValue(done);
vi.mocked(api.startTableExport).mockResolvedValue(done);
});
afterEach(() => {
const tracker = useExportTracker();
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
});
it("passes full-result selection to backend without changing query, paging or metadata", async () => {
const choices = sqlExportColumnChoices(["id", "name", "id"]);
vi.mocked(showSqlInsertModeDialog).mockResolvedValue({ insertMode: "single", selectedColumns: [choices[2]!] });
const state = useDataGridExport(createOptions({ allColumns: computed(() => ["id", "name", "id"]), allColumnTypes: computed(() => ["int", "text", "bigint"]) }));
await state.exportSql();
expect(showSqlInsertModeDialog).toHaveBeenCalledWith({ allowSplit: false, columns: choices });
expect(api.startQueryResultExport).toHaveBeenCalledWith(expect.objectContaining({ selectedColumns: [choices[2]!], exportColumnTypes: ["int", "text", "bigint"], insertMode: "single", keysetOptimizationEnabled: true, sql: "SELECT id, name FROM users" }), expect.any(Function));
});
it("preserves full table paging keys and split options in the backend request", async () => {
const selectedColumns = [sqlExportColumnChoices(["id", "name"])[1]!];
vi.mocked(showSqlInsertModeDialog).mockResolvedValue({ insertMode: "batch", selectedColumns, splitMaxMb: 128 });
await useDataGridExport(createOptions({ context: computed(() => "table-data") })).exportSql();
expect(api.startTableExport).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "name"], columnTypes: ["int4", "text"], selectedColumns, splitMaxMb: 128 }), expect.any(Function));
expect(api.startQueryResultExport).not.toHaveBeenCalled();
});
it.each(["page", "selected"] as const)("projects %s rows and types together without requerying", async (scope) => {
const state = useDataGridExport(createOptions());
if (scope === "page") await state.exportCurrentPageSql();
else await state.exportSql([8]);
expect(formatSqlInsert).toHaveBeenCalledWith(expect.objectContaining({ columns: ["name"], columnTypes: ["text"], rows: scope === "page" ? [["Ada"], ["Lin"]] : [["Lin"]], insertMode: "single" }));
expect(api.startQueryResultExport).not.toHaveBeenCalled();
expect(api.startTableExport).not.toHaveBeenCalled();
expect(saveTextFile).toHaveBeenCalledOnce();
});
it("keeps second duplicate, spatial metadata and raw types aligned after internal-key removal", async () => {
const names = ["__DBX_ROWID", "id", "id", "shape"];
const choices = sqlExportColumnChoices(names);
vi.mocked(showSqlInsertModeDialog).mockResolvedValue({ insertMode: "batch", selectedColumns: [choices[2]!, choices[3]!] });
const result = { columns: names, column_types: ["ROWID", "int", "bigint", "geometry"], rows: [["key", 1, 20, "POINT(1 2)"]], hidden_column_indexes: [0], spatial_columns: [{ column_index: 3, srid: 4326 }], spatial_values: [[null, null, null, 4326]] } as QueryResult;
await useDataGridExport(createOptions({ databaseType: computed(() => "oracle"), allColumns: computed(() => names), hasCompleteLocalResult: computed(() => true), completeLocalResult: computed(() => result) })).exportSql();
expect(showSqlInsertModeDialog).toHaveBeenCalledWith({ allowSplit: false, columns: choices.slice(1) });
expect(formatSqlInsert).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "shape"], columnTypes: ["bigint", "geometry"], rows: [[20, "POINT(1 2)"]], spatialColumns: [{ column_index: 1, srid: 4326 }], spatialValues: [[null, 4326]] }));
expect(api.startQueryResultExport).not.toHaveBeenCalled();
});
it("applies the selection to the Web full-result fallback", async () => {
mocks.native = false;
const fullExportResult = vi.fn(async () => ({ columns: ["id", "name"], column_types: ["int", "varchar"], rows: [[3, "Grace"]] }) as QueryResult);
await useDataGridExport(createOptions({ fullExportResult })).exportSql();
expect(fullExportResult).toHaveBeenCalledOnce();
expect(formatSqlInsert).toHaveBeenCalledWith(expect.objectContaining({ columns: ["name"], columnTypes: ["text"], rows: [["Grace"]] }));
});
it("does not remap reordered full-table results by visible positions", async () => {
mocks.native = false;
const fullExportResult = vi.fn(async () => ({ columns: ["name", "id"], column_types: ["varchar", "bigint"], rows: [["Grace", 3]] }) as QueryResult);
await useDataGridExport(createOptions({ context: computed(() => "table-data"), tableMeta: computed(() => ({ tableName: "users", catalog: "external", primaryKeys: [], columns: [] })), fullExportResult })).exportSql();
expect(formatSqlInsert).toHaveBeenCalledWith(expect.objectContaining({ columns: ["name"], columnTypes: ["varchar"], rows: [["Grace"]] }));
});
it("omits generated MySQL columns from the chooser but preserves identity and expression defaults", async () => {
vi.mocked(showSqlInsertModeDialog).mockResolvedValue(null);
const names = ["id", "generated", "defaulted", "identity"];
await useDataGridExport(
createOptions({
context: computed(() => "table-data"),
databaseType: computed(() => "mysql"),
columns: computed(() => names),
sourceColumns: computed(() => names),
tableMeta: computed(() => ({
tableName: "users",
primaryKeys: ["id"],
columns: [
{ name: "id", data_type: "int", extra: "auto_increment" },
{ name: "generated", data_type: "text", extra: "VIRTUAL GENERATED" },
{ name: "defaulted", data_type: "timestamp", extra: "DEFAULT_GENERATED" },
{ name: "identity", data_type: "int", extra: "auto_increment" },
],
})),
}),
).exportSql();
expect(showSqlInsertModeDialog).toHaveBeenCalledWith({ allowSplit: true, columns: sqlExportColumnChoices(names).filter((column) => column.name !== "generated") });
});
it("omits PostgreSQL tsvector fields using query metadata", async () => {
vi.mocked(showSqlInsertModeDialog).mockResolvedValue(null);
await useDataGridExport(createOptions({ allColumns: computed(() => ["id", "search"]), allColumnTypes: computed(() => ["int", "pg_catalog.tsvector"]) })).exportSql();
expect(showSqlInsertModeDialog).toHaveBeenCalledWith({ allowSplit: false, columns: sqlExportColumnChoices(["id"]) });
});
it("does not export after cancellation or an invalid empty selection", async () => {
const state = useDataGridExport(createOptions());
vi.mocked(showSqlInsertModeDialog).mockResolvedValue(null);
await state.exportSql();
vi.mocked(showSqlInsertModeDialog).mockResolvedValue({ insertMode: "batch", selectedColumns: [] });
await state.exportCurrentPageSql();
expect(api.startQueryResultExport).not.toHaveBeenCalled();
expect(formatSqlInsert).not.toHaveBeenCalled();
expect(saveTextFile).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledWith("grid.exportFailed", 5000);
});
it("keeps legacy omitted selection behavior", async () => {
vi.mocked(showSqlInsertModeDialog).mockResolvedValue({ insertMode: "batch" });
await useDataGridExport(createOptions()).exportCurrentPageSql();
expect(formatSqlInsert).toHaveBeenCalledWith(
expect.objectContaining({
columns: ["id", "name"],
rows: [
[1, "Ada"],
[2, "Lin"],
],
}),
);
});
});
@@ -15,7 +15,8 @@ import { clipboardLineEndings, 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 SqlExportOptions, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { showSqlInsertModeDialog, type SqlExportColumnSelection, type SqlExportOptions, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { resolveSqlExportColumnIndexes, sqlExportColumnChoices } from "@/lib/export/sqlExportColumns";
import { summarizeExportRows } from "@/lib/export/exportDiagnostics";
import { appendDebugLog, appendNativeProcessMemoryLog, getBrowserMemorySnapshot, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { uuid } from "@/lib/common/utils";
@@ -339,7 +340,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
}
function normalizeCompleteLocalResult(result: QueryResult): { columns: string[]; columnTypes: string[]; columnComments: Array<string | undefined>; rows: CellValue[][]; mongoCopyDocuments?: unknown[] } {
function normalizeCompleteLocalResult(result: QueryResult): { columns: string[]; columnTypes: string[]; columnComments: Array<string | undefined>; rows: CellValue[][]; mongoCopyDocuments?: unknown[]; spatialColumns?: QueryResult["spatial_columns"]; spatialValues?: QueryResult["spatial_values"] } {
const editorSettings = useSettingsStore().editorSettings;
if (databaseType.value === "mongodb") {
const projected = projectResultColumns(result, columns.value);
@@ -350,6 +351,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
columnComments: commentsForExportColumns(projected.columns),
rows,
mongoCopyDocuments: result.mongo_copy_documents?.slice(0, rows.length),
spatialColumns: projected.spatialColumns,
spatialValues: projected.spatialValues?.slice(0, rows.length),
};
}
@@ -357,6 +360,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
const exportedColumnIndexes = result.columns.map((_, index) => index).filter((index) => !hiddenColumnIndexes.has(index));
const hasHiddenColumns = exportedColumnIndexes.length !== result.columns.length;
const rows = editorSettings.exportRowLimitEnabled ? result.rows.slice(0, editorSettings.exportRowLimit) : result.rows;
const targetIndexBySource = new Map(exportedColumnIndexes.map((sourceIndex, index) => [sourceIndex, index]));
// Internal key columns are query-only metadata. Keep every user column,
// including columns hidden manually in the grid, while preserving alignment.
@@ -366,6 +370,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
columnComments: hasHiddenColumns ? exportedColumnIndexes.map((index) => allXlsxColumnComments.value[index]) : [...allXlsxColumnComments.value],
rows: hasHiddenColumns ? rows.map((row) => exportedColumnIndexes.map((index) => row[index])) : rows,
mongoCopyDocuments: result.mongo_copy_documents?.slice(0, rows.length),
spatialColumns: result.spatial_columns?.flatMap((column) => {
const columnIndex = targetIndexBySource.get(column.column_index);
return columnIndex === undefined ? [] : [{ ...column, column_index: columnIndex }];
}),
spatialValues: result.spatial_values?.slice(0, rows.length).map((row) => exportedColumnIndexes.map((index) => row[index] ?? null)),
};
}
@@ -473,8 +482,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
formatDateTime && !preserveMongoExtendedJson,
),
columnComments,
spatialColumns: completeLocalResult.value.spatial_columns,
spatialValues: completeLocalResult.value.spatial_values,
spatialColumns: normalized.spatialColumns,
spatialValues: normalized.spatialValues,
};
}
const commentHeader = buildXlsxHeaderOverrides(columns.value, visibleXlsxColumnComments.value, headerMode);
@@ -1378,11 +1387,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
tableName: meta.tableName,
filePath: outputPath,
format,
...(format === "sql" && sqlExportOptions ? { insertMode: sqlExportOptions.insertMode, splitMaxMb: sqlExportOptions.splitMaxMb } : {}),
...(format === "sql" && sqlExportOptions ? { insertMode: sqlExportOptions.insertMode, splitMaxMb: sqlExportOptions.splitMaxMb, selectedColumns: sqlExportOptions.selectedColumns } : {}),
csvQuoteMode: editorSettings.csvQuoteMode,
columns: columns.value,
columns: format === "sql" ? effectiveColumns(sourceColumns.value, columns.value).map((column, index) => column ?? columns.value[index]!) : columns.value,
columnTypes: columnTypes.value,
...(format === "sql" ? { columnExtras: sqlExportColumnExtras(columns.value) } : {}),
...(format === "sql" ? { columnExtras: sqlExportColumnExtras(effectiveColumns(sourceColumns.value, columns.value).map((column, index) => column ?? columns.value[index]!)) } : {}),
columnComments: format === "xlsx" ? buildXlsxHeaderOverrides(columns.value, visibleXlsxColumnComments.value, headerMode) : undefined,
primaryKeys: meta.primaryKeys,
...sqlExportPrimaryKeyOptions(),
@@ -1421,7 +1430,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return true;
}
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "json" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode?: SqlInsertMode): Promise<boolean> {
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "json" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, headerMode: XlsxHeaderMode = "name", autoFilter = true, sqlExportOptions?: SqlExportOptions): Promise<boolean> {
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) {
return false;
}
@@ -1453,12 +1462,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportTableName: format === "sql" ? tableMeta.value?.tableName : undefined,
exportColumnTypes: format === "sql" ? allColumnTypes.value?.map((type) => type ?? null) : undefined,
exportColumnExtras: format === "sql" ? sqlExportColumnExtras(allColumns.value) : undefined,
...(format === "sql" && insertMode ? { insertMode } : {}),
...(format === "sql" && sqlExportOptions ? { insertMode: sqlExportOptions.insertMode } : {}),
});
const columnComments = format === "xlsx" ? buildXlsxHeaderOverrides(allColumns.value, allXlsxColumnComments.value, headerMode) : undefined;
const request = baseRequest
? {
...baseRequest,
...(format === "sql" ? { selectedColumns: sqlExportOptions?.selectedColumns } : {}),
csvQuoteMode: useSettingsStore().editorSettings.csvQuoteMode,
...sqlExportPrimaryKeyOptions(),
dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined,
@@ -1537,9 +1547,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return true;
}
async function exportQueryResultSqlViaBackend(rowIds: number[] | undefined, insertMode: SqlInsertMode): Promise<boolean> {
async function exportQueryResultSqlViaBackend(rowIds: number[] | undefined, sqlExportOptions: SqlExportOptions): Promise<boolean> {
if (!isTauriRuntime()) return false;
return exportQueryResultViaBackend("sql", rowIds, false, "name", true, insertMode);
return exportQueryResultViaBackend("sql", rowIds, false, "name", true, sqlExportOptions);
}
function sqlExportChoices(fullResult: boolean): SqlExportColumnSelection[] {
const names = context.value === "table-data" ? effectiveColumns(sourceColumns.value, columns.value) : fullResult ? allColumns.value : columns.value;
const excluded = new Set((sqlExportExcludedColumns() ?? []).map(normalizeColumnName));
const types = fullResult && context.value === "results" ? allColumnTypes.value : columnTypes.value;
const metadataByName = new Map((tableMeta.value?.columns ?? []).map((column) => [normalizeColumnName(column.name), column]));
return sqlExportColumnChoices(names).filter((column) => {
if (excluded.has(normalizeColumnName(column.name)) || usesSyntheticRowIdKey(databaseType.value, [column.name])) return false;
const metadata = metadataByName.get(normalizeColumnName(column.name));
if (databaseType.value === "mysql" && /\b(?:virtual|stored|persistent)\s+generated\b|\bgenerated\s+always\s+as\s*\(/i.test(metadata?.extra ?? "")) return false;
const columnType = (metadata?.data_type || types?.[column.sourceIndex])?.trim().replace(/^"|"$/g, "").toLowerCase();
return databaseType.value !== "postgres" || (columnType !== "tsvector" && !columnType?.endsWith(".tsvector"));
});
}
async function exportSql(rowIds?: number[]) {
@@ -1568,12 +1592,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
},
true,
);
const selectedSqlExportOptions = (await showSqlInsertModeDialog({ allowSplit: rowIds === undefined && context.value === "table-data" })) as SqlExportOptions | SqlInsertMode | null;
const selectedSqlExportOptions = (await showSqlInsertModeDialog({ allowSplit: rowIds === undefined && context.value === "table-data", columns: sqlExportChoices(rowIds === undefined) })) as SqlExportOptions | SqlInsertMode | null;
if (selectedSqlExportOptions === null) {
logExportStage("cancelled", { stage: "insert-mode-dialog" });
return;
}
const sqlExportOptions = typeof selectedSqlExportOptions === "string" ? { insertMode: selectedSqlExportOptions } : selectedSqlExportOptions;
const sqlExportOptions: SqlExportOptions = typeof selectedSqlExportOptions === "string" ? { insertMode: selectedSqlExportOptions } : selectedSqlExportOptions;
const insertMode = sqlExportOptions.insertMode;
logExportStage("mode-selected", { insertMode, splitMaxMb: sqlExportOptions.splitMaxMb });
try {
@@ -1588,7 +1612,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
// Step 2: query-result context — NEW backend streaming with background task
logExportStage("query-backend-export-start");
const handledQueryByBackend = await exportQueryResultSqlViaBackend(rowIds, insertMode);
const handledQueryByBackend = await exportQueryResultSqlViaBackend(rowIds, sqlExportOptions);
logExportStage("query-backend-export-finished", { handledByBackend: handledQueryByBackend });
if (handledQueryByBackend) {
logExportStage("done", { path: "query-backend" });
@@ -1608,7 +1632,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
);
logExportStage("row-remap-start");
const exportData = sqlInsertExportData(result);
const exportData = sqlInsertExportData(result, sqlExportOptions.selectedColumns);
logExportStage("row-remap-done", {
columns: exportData.columns.length,
rows: exportData.rows.length,
@@ -1653,12 +1677,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function exportCurrentPageSql() {
await runExclusiveExport(async () => {
const selectedSqlExportOptions = (await showSqlInsertModeDialog()) as SqlExportOptions | SqlInsertMode | null;
const selectedSqlExportOptions = (await showSqlInsertModeDialog({ columns: sqlExportChoices(false) })) as SqlExportOptions | SqlInsertMode | null;
if (selectedSqlExportOptions === null) return;
const insertMode = typeof selectedSqlExportOptions === "string" ? selectedSqlExportOptions : selectedSqlExportOptions.insertMode;
const sqlExportOptions: SqlExportOptions = typeof selectedSqlExportOptions === "string" ? { insertMode: selectedSqlExportOptions } : selectedSqlExportOptions;
const insertMode = sqlExportOptions.insertMode;
try {
const result = await resultToExport(undefined, undefined, false, false);
const exportData = sqlInsertExportData(result);
const exportData = sqlInsertExportData(result, sqlExportOptions.selectedColumns);
const content = await formatSqlInsert({
databaseType: databaseType.value,
identifierQuote: options.identifierQuote?.value,
@@ -1686,7 +1711,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
await copyText(sql.value);
}
function sqlInsertExportData(result: { columns: string[]; rows: CellValue[][]; spatialColumns?: QueryResult["spatial_columns"]; spatialValues?: QueryResult["spatial_values"] }): {
function sqlInsertExportData(
result: { columns: string[]; columnTypes?: Array<string | undefined>; rows: CellValue[][]; spatialColumns?: QueryResult["spatial_columns"]; spatialValues?: QueryResult["spatial_values"] },
selectedColumns?: SqlExportColumnSelection[],
): {
columns: string[];
columnTypes?: Array<string | undefined>;
columnExtras?: Array<string | null>;
@@ -1694,9 +1722,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
spatialValues?: QueryResult["spatial_values"];
rows: CellValue[][];
} {
const exportColumns = context.value === "table-data" && tableMeta.value ? effectiveColumns(sourceColumns.value, result.columns) : result.columns;
const columnIndexes = exportColumns.map((column, index) => ({ column, index })).filter((item): item is { column: string; index: number } => !!item.column);
const exportColumnTypes = columnTypes.value?.length === result.columns.length ? columnTypes.value : undefined;
const matchesVisibleColumns = result.columns.length === columns.value.length && result.columns.every((column, index) => column === columns.value[index]);
const exportColumns = context.value === "table-data" && tableMeta.value && matchesVisibleColumns ? effectiveColumns(sourceColumns.value, result.columns) : result.columns;
const columnIndexes = resolveSqlExportColumnIndexes(exportColumns, selectedColumns)
.map((index) => ({ column: exportColumns[index], index }))
.filter((item): item is { column: string; index: number } => !!item.column);
const exportColumnTypes = matchesVisibleColumns && columnTypes.value?.length === result.columns.length ? columnTypes.value : result.columnTypes?.length === result.columns.length ? result.columnTypes : undefined;
const metaColumns = tableMeta.value?.columns;
const exportColumnExtras = metaColumns?.length ? exportColumns.map((column) => (column ? (metaColumns.find((meta) => normalizeColumnName(meta.name) === normalizeColumnName(column))?.extra ?? null) : null)) : undefined;
const indexBySource = new Map(columnIndexes.map((item, index) => [item.index, index]));
+5
View File
@@ -8521,6 +8521,11 @@ export default withEnglishFallback({
dropTableIfExists: "DDL-dən əvvəl DROP TABLE IF EXISTS ifadəsini əlavə et",
omitAutoIncrement: "AUTO_INCREMENT parametrini daxil etmə (yeni quraşdırma skriptləri üçün)",
includeData: "Cədvəl məlumatları (INSERT)",
sqlColumnSelection: "İxrac ediləcək sütunlar",
selectedColumns: "{selected}/{total} sütun seçilib",
selectAllColumns: "Hamısını seç",
clearColumns: "Təmizlə",
selectAtLeastOneColumn: "İxrac üçün ən azı bir sütun seçin.",
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)",
+5
View File
@@ -9235,6 +9235,11 @@ export default {
dropTableIfExists: "Add DROP TABLE IF EXISTS before DDL",
omitAutoIncrement: "Omit AUTO_INCREMENT (for fresh-install scripts)",
includeData: "Table data (INSERT)",
sqlColumnSelection: "Columns to export",
selectedColumns: "{selected}/{total} columns selected",
selectAllColumns: "Select all",
clearColumns: "Clear",
selectAtLeastOneColumn: "Select at least one column to export.",
standardSqlInsert: "Use Standard SQL INSERT for table data",
standardSqlInsertDescription: "Uses ANSI double-quoted identifiers and portable scalar literals. DDL and other objects keep the source database dialect.",
includeObjects: "Views / Procedures / Functions / Sequences",
+5
View File
@@ -8570,6 +8570,11 @@ export default withEnglishFallback({
dropTableIfExists: "Agregar DROP TABLE IF EXISTS antes del DDL",
omitAutoIncrement: "Omitir AUTO_INCREMENT (para scripts de instalación nueva)",
includeData: "Datos de tablas (INSERT)",
sqlColumnSelection: "Columnas para exportar",
selectedColumns: "{selected}/{total} columnas seleccionadas",
selectAllColumns: "Seleccionar todas",
clearColumns: "Limpiar",
selectAtLeastOneColumn: "Selecciona al menos una columna para exportar.",
includeObjects: "Vistas / Procedimientos / Funciones / Secuencias",
splitSqlOutput: "Dividir la exportación SQL en un archivo ZIP",
splitSqlPartMaxMb: "Tamaño máximo por parte SQL (MB)",
+5
View File
@@ -8496,6 +8496,11 @@ export default withEnglishFallback({
dropTableIfExists: "Aggiungi DROP TABLE IF EXISTS prima del DDL",
omitAutoIncrement: "Ometti AUTO_INCREMENT (per script di installazione pulita)",
includeData: "Dati tabella (INSERT)",
sqlColumnSelection: "Colonne da esportare",
selectedColumns: "{selected}/{total} colonne selezionate",
selectAllColumns: "Seleziona tutto",
clearColumns: "Cancella",
selectAtLeastOneColumn: "Seleziona almeno una colonna da esportare.",
includeObjects: "Viste / Procedure / Funzioni / Sequenze",
splitSqlOutput: "Dividere l'esportazione SQL in un archivio ZIP",
splitSqlPartMaxMb: "Dimensione massima per parte SQL (MB)",
+5
View File
@@ -8539,6 +8539,11 @@ export default withEnglishFallback({
dropTableIfExists: "DDLの前にDROP TABLE IF EXISTSを追加",
omitAutoIncrement: "AUTO_INCREMENT を省略(新規インストール用スクリプト向け)",
includeData: "テーブルデータ (INSERT)",
sqlColumnSelection: "エクスポートする列",
selectedColumns: "{selected}/{total} 列を選択",
selectAllColumns: "すべて選択",
clearColumns: "クリア",
selectAtLeastOneColumn: "エクスポートする列を1つ以上選択してください。",
includeObjects: "ビュー / プロシージャ / 関数 / シーケンス",
splitSqlOutput: "SQLエクスポートをZIPアーカイブに分割",
splitSqlPartMaxMb: "SQLパートあたりの最大サイズ(MB)",
+5
View File
@@ -8236,6 +8236,11 @@ export default withEnglishFallback({
dropTableIfExists: "DDL 앞에 DROP TABLE IF EXISTS 추가",
omitAutoIncrement: "AUTO_INCREMENT 생략 (초기 설치 스크립트용)",
includeData: "테이블 데이터 (INSERT)",
sqlColumnSelection: "내보낼 열",
selectedColumns: "{selected}/{total}개 열 선택됨",
selectAllColumns: "모두 선택",
clearColumns: "지우기",
selectAtLeastOneColumn: "내보낼 열을 하나 이상 선택하세요.",
includeObjects: "뷰 / 프로시저 / 함수 / 시퀀스",
splitSqlOutput: "SQL 내보내기를 ZIP 압축 파일로 분할",
splitSqlPartMaxMb: "SQL 파트당 최대 크기(MB)",
+5
View File
@@ -8496,6 +8496,11 @@ export default withEnglishFallback({
dropTableIfExists: "Adicionar DROP TABLE IF EXISTS antes do DDL",
omitAutoIncrement: "Omitir AUTO_INCREMENT (para scripts de instalação do zero)",
includeData: "Dados da tabela (INSERT)",
sqlColumnSelection: "Colunas para exportar",
selectedColumns: "{selected}/{total} colunas selecionadas",
selectAllColumns: "Selecionar todas",
clearColumns: "Limpar",
selectAtLeastOneColumn: "Selecione pelo menos uma coluna para exportar.",
includeObjects: "Views / Procedures / Funções / Sequências",
splitSqlOutput: "Dividir exportação SQL em arquivo ZIP",
splitSqlPartMaxMb: "Tamanho máximo por parte SQL (MB)",
+5
View File
@@ -9727,6 +9727,11 @@ export default withEnglishFallback({
dropTableIfExists: "Добавлять DROP TABLE IF EXISTS перед DDL",
omitAutoIncrement: "Пропускать AUTO_INCREMENT (для скриптов чистой установки)",
includeData: "Данные таблиц (INSERT)",
sqlColumnSelection: "Столбцы для экспорта",
selectedColumns: "Выбрано столбцов: {selected}/{total}",
selectAllColumns: "Выбрать все",
clearColumns: "Очистить",
selectAtLeastOneColumn: "Выберите хотя бы один столбец для экспорта.",
includeObjects: "Представления / Процедуры / Функции / Последовательности",
splitSqlOutput: "Разделить экспорт SQL на ZIP-архив",
splitSqlPartMaxMb: "Максимальный размер одной части SQL (МБ)",
+5
View File
@@ -8391,6 +8391,11 @@ export default withEnglishFallback({
dropTableIfExists: "DDL'den önce DROP TABLE IF EXISTS ekle",
omitAutoIncrement: "AUTO_INCREMENT'i atla (sıfırdan kurulum betikleri için)",
includeData: "Tablo verisi (INSERT)",
sqlColumnSelection: "Dışa aktarılacak sütunlar",
selectedColumns: "{selected}/{total} sütun seçildi",
selectAllColumns: "Tümünü seç",
clearColumns: "Temizle",
selectAtLeastOneColumn: "Dışa aktarmak için en az bir sütun seçin.",
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)",
+5
View File
@@ -9198,6 +9198,11 @@ export default withEnglishFallback({
dropTableIfExists: "导出前添加 DROP TABLE IF EXISTS",
omitAutoIncrement: "省略 AUTO_INCREMENT(用于全新安装的初始化脚本)",
includeData: "表数据 (INSERT)",
sqlColumnSelection: "要导出的列",
selectedColumns: "已选择 {selected}/{total} 列",
selectAllColumns: "全选",
clearColumns: "清空",
selectAtLeastOneColumn: "请至少选择一列导出。",
standardSqlInsert: "表数据使用标准 SQL INSERT",
standardSqlInsertDescription: "使用 ANSI 双引号标识符和通用标量字面量;DDL 与其他对象仍保留源数据库方言。",
includeObjects: "视图 / 存储过程 / 函数 / 序列",
+5
View File
@@ -7821,6 +7821,11 @@ export default withEnglishFallback({
dropTableIfExists: "匯出前新增 DROP TABLE IF EXISTS",
omitAutoIncrement: "省略 AUTO_INCREMENT(用於全新安裝的初始化腳本)",
includeData: "資料表資料 (INSERT)",
sqlColumnSelection: "要匯出的欄位",
selectedColumns: "已選取 {selected}/{total} 個欄位",
selectAllColumns: "全選",
clearColumns: "清除",
selectAtLeastOneColumn: "請至少選取一個欄位匯出。",
standardSqlInsert: "資料表資料使用標準 SQL INSERT",
standardSqlInsertDescription: "使用 ANSI 雙引號識別碼和通用純量常值;DDL 與其他物件仍保留來源資料庫方言。",
includeObjects: "檢視 / 預存程序 / 函式 / 序列",
+3 -1
View File
@@ -51,7 +51,7 @@ import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type Meil
import type { XuguTablespaceInfo } from "@/types/database";
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchCreateIndexInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode";
import type { SqlInsertDialect, SqlInsertMode } from "@/lib/export/sqlInsertMode";
import type { SqlExportColumnSelection, SqlInsertDialect, SqlInsertMode } from "@/lib/export/sqlInsertMode";
/** Normalize Tauri rejections once at the public backend boundary. */
async function invokeBackend<T>(command: string, args?: Record<string, unknown>): Promise<T> {
@@ -5792,6 +5792,7 @@ export interface TableExportRequest {
insertDialect?: SqlInsertDialect;
csvQuoteMode?: CsvQuoteMode;
columns?: string[];
selectedColumns?: SqlExportColumnSelection[];
columnTypes?: Array<string | null | undefined>;
/** 与 `columns` 对齐的列 EXTRA 元数据(identity 等),用于 SQL INSERT 导出的 `SET IDENTITY_INSERT`。 */
columnExtras?: Array<string | null | undefined>;
@@ -5857,6 +5858,7 @@ export interface QueryResultExportRequest {
dateTimeFormat?: string;
exportTableName?: string;
exportColumnTypes?: Array<string | null | undefined>;
selectedColumns?: SqlExportColumnSelection[];
/**
* 结果列对应的原表 EXTRA 元数据(identity 等)。后端据此为 SQL INSERT 导出
* 补上 `SET IDENTITY_INSERT` 包裹,缺省表示未知。
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveSqlExportColumnIndexes, sqlExportColumnChoices } from "@/lib/export/sqlExportColumns";
describe("SQL export column identities", () => {
it("retains source positions and distinguishes duplicate labels", () => {
expect(sqlExportColumnChoices(["id", undefined, "name", "id"])).toEqual([
{ sourceIndex: 0, name: "id", nameOccurrence: 0 },
{ sourceIndex: 2, name: "name", nameOccurrence: 0 },
{ sourceIndex: 3, name: "id", nameOccurrence: 1 },
]);
});
it("resolves duplicate occurrences after columns are reordered or internal keys removed", () => {
const choices = sqlExportColumnChoices(["__DBX_ROWID", "id", "name", "id"]);
expect(resolveSqlExportColumnIndexes(["id", "id", "name"], [choices[3]!, choices[2]!])).toEqual([1, 2]);
});
it("preserves omitted selection and rejects empty, missing and repeated selections", () => {
const choices = sqlExportColumnChoices(["id", "id"]);
expect(resolveSqlExportColumnIndexes(["id", "name"])).toEqual([0, 1]);
expect(() => resolveSqlExportColumnIndexes(["id"], [])).toThrow("at least one");
expect(() => resolveSqlExportColumnIndexes(["id"], [choices[1]!])).toThrow("not found");
expect(() => resolveSqlExportColumnIndexes(["id"], [choices[0]!, choices[0]!])).toThrow("more than once");
});
it("handles wide projections without collapsing equal labels", () => {
const columns = Array.from({ length: 10000 }, () => "value");
const choices = sqlExportColumnChoices(columns);
expect(resolveSqlExportColumnIndexes(columns, choices)).toEqual(columns.map((_, index) => index));
});
});
@@ -0,0 +1,31 @@
import type { SqlExportColumnSelection } from "@/lib/export/sqlInsertMode";
export function sqlExportColumnChoices(columns: readonly (string | undefined)[]): SqlExportColumnSelection[] {
const occurrences = new Map<string, number>();
return columns.flatMap((name, sourceIndex) => {
if (!name) return [];
const nameOccurrence = occurrences.get(name) ?? 0;
occurrences.set(name, nameOccurrence + 1);
return [{ sourceIndex, name, nameOccurrence }];
});
}
export function resolveSqlExportColumnIndexes(columns: readonly (string | undefined)[], selectedColumns?: readonly SqlExportColumnSelection[]): number[] {
if (selectedColumns === undefined) return columns.map((_, index) => index);
if (!selectedColumns.length) throw new Error("Select at least one column for SQL export.");
const indexesByName = new Map<string, number[]>();
columns.forEach((name, index) => {
if (!name) return;
const indexes = indexesByName.get(name) ?? [];
indexes.push(index);
indexesByName.set(name, indexes);
});
const used = new Set<number>();
return selectedColumns.map((column) => {
const index = indexesByName.get(column.name)?.[column.nameOccurrence];
if (index === undefined) throw new Error(`Selected SQL export column was not found: ${column.name}`);
if (used.has(index)) throw new Error(`SQL export column was selected more than once: ${column.name}`);
used.add(index);
return index;
});
}
+15 -2
View File
@@ -5,16 +5,28 @@ import i18n from "@/i18n";
export type SqlInsertMode = "batch" | "single";
export type SqlInsertDialect = "source" | "standard";
export interface SqlExportColumnSelection {
sourceIndex: number;
name: string;
nameOccurrence: number;
}
export interface SqlExportOptions {
insertMode: SqlInsertMode;
splitMaxMb?: number;
selectedColumns?: SqlExportColumnSelection[];
}
export const DEFAULT_SQL_INSERT_MODE: SqlInsertMode = "batch";
export const DEFAULT_SQL_INSERT_DIALECT: SqlInsertDialect = "source";
export function showSqlInsertModeDialog(options: { allowSplit?: boolean } = {}): Promise<SqlExportOptions | null> {
if (typeof document === "undefined") return Promise.resolve({ insertMode: DEFAULT_SQL_INSERT_MODE });
export function showSqlInsertModeDialog(options: { allowSplit?: boolean; columns?: SqlExportColumnSelection[] } = {}): Promise<SqlExportOptions | null> {
if (typeof document === "undefined") {
return Promise.resolve({
insertMode: DEFAULT_SQL_INSERT_MODE,
...(options.columns ? { selectedColumns: [...options.columns] } : {}),
});
}
return new Promise((resolve) => {
const container = document.createElement("div");
@@ -31,6 +43,7 @@ export function showSqlInsertModeDialog(options: { allowSplit?: boolean } = {}):
app = createApp(SqlInsertModeDialog, {
open: true,
allowSplit: options.allowSplit === true,
columns: options.columns,
onConfirm: (options: SqlExportOptions) => finish(options),
onCancel: () => finish(null),
});
+159 -1
View File
@@ -22,7 +22,7 @@ use crate::transfer::{
keyset_pagination_sql_with_identifier_quote, quote_identifier, quote_postgres_string_literal,
wrap_dameng_identity_insert_sql_for_table,
};
use crate::types::{ObjectSourceKind, SpatialColumn};
use crate::types::{ObjectSourceKind, SpatialColumn, SqlExportColumnSelection};
static EXPORT_CANCELLED: std::sync::LazyLock<RwLock<HashSet<String>>> =
std::sync::LazyLock::new(|| RwLock::new(HashSet::new()));
@@ -1444,6 +1444,164 @@ pub(crate) fn is_internal_export_column(database_type: Option<DatabaseType>, col
&& column.eq_ignore_ascii_case(crate::sql_dialect::DBX_ROWID_COLUMN)
}
#[derive(Debug, Clone)]
pub(crate) struct SqlExportProjection {
entries: Vec<(usize, usize)>,
}
impl SqlExportProjection {
pub(crate) fn resolve(
columns: &[String],
selected_columns: Option<&[SqlExportColumnSelection]>,
) -> Result<Self, String> {
let Some(selected_columns) = selected_columns else {
return Ok(Self { entries: (0..columns.len()).map(|index| (index, index)).collect() });
};
if selected_columns.is_empty() {
return Err("Select at least one column for SQL export.".to_string());
}
let mut entries = Vec::with_capacity(selected_columns.len());
let mut used = HashSet::with_capacity(selected_columns.len());
let mut indexes_by_name = HashMap::<&str, Vec<usize>>::new();
for (index, name) in columns.iter().enumerate() {
indexes_by_name.entry(name.as_str()).or_default().push(index);
}
for selected in selected_columns {
let current_index = indexes_by_name
.get(selected.name.as_str())
.and_then(|indexes| indexes.get(selected.name_occurrence))
.copied()
.ok_or_else(|| format!("Selected SQL export column was not found: {}", selected.name))?;
if !used.insert(current_index) {
return Err(format!("SQL export column was selected more than once: {}", selected.name));
}
entries.push((current_index, selected.source_index));
}
Ok(Self { entries })
}
pub(crate) fn project<T: Clone + Default>(&self, values: &[T]) -> Vec<T> {
self.entries.iter().map(|(current_index, _)| values.get(*current_index).cloned().unwrap_or_default()).collect()
}
pub(crate) fn project_request<T: Clone + Default>(&self, values: &[T]) -> Vec<T> {
self.entries.iter().map(|(_, request_index)| values.get(*request_index).cloned().unwrap_or_default()).collect()
}
pub(crate) fn project_owned<T: Default>(&self, mut values: Vec<T>) -> Vec<T> {
self.entries.iter().map(|(index, _)| values.get_mut(*index).map(std::mem::take).unwrap_or_default()).collect()
}
pub(crate) fn project_spatial_columns(&self, columns: &[SpatialColumn]) -> Vec<SpatialColumn> {
let spatial_by_index: HashMap<_, _> = columns.iter().map(|column| (column.column_index, column)).collect();
self.entries
.iter()
.enumerate()
.filter_map(|(projected_index, (current_index, _))| {
spatial_by_index
.get(current_index)
.map(|column| SpatialColumn { column_index: projected_index, srid: column.srid })
})
.collect()
}
pub(crate) fn project_insert_options(
&self,
mut options: BuildExportInsertStatementsOptions,
) -> BuildExportInsertStatementsOptions {
options.columns = self.project_owned(options.columns);
options.column_types = self.project_owned(options.column_types);
options.column_extras = self.project_owned(options.column_extras);
options.spatial_columns = self.project_spatial_columns(&options.spatial_columns);
options.spatial_values = options.spatial_values.into_iter().map(|row| self.project_owned(row)).collect();
options.rows = options.rows.into_iter().map(|row| self.project_owned(row)).collect();
options
}
}
#[cfg(test)]
mod sql_export_projection_tests {
use super::*;
use serde_json::json;
fn selection(index: usize, name: &str, occurrence: usize) -> SqlExportColumnSelection {
SqlExportColumnSelection { source_index: index, name: name.into(), name_occurrence: occurrence }
}
#[test]
fn omitted_selection_keeps_every_column_but_empty_selection_is_rejected() {
let columns = vec!["id".into(), "name".into()];
let projection = SqlExportProjection::resolve(&columns, None).unwrap();
assert_eq!(projection.project(&columns), columns);
assert!(SqlExportProjection::resolve(&columns, Some(&[])).unwrap_err().contains("at least one"));
}
#[test]
fn duplicate_names_follow_occurrence_after_reordering() {
let columns = vec!["id".into(), "id".into(), "name".into()];
let projection =
SqlExportProjection::resolve(&columns, Some(&[selection(3, "id", 1), selection(2, "name", 0)])).unwrap();
assert_eq!(projection.project(&[json!(1), json!(2), json!("Ada")]), vec![json!(2), json!("Ada")]);
assert_eq!(projection.project_request(&[0, 1, 2, 3]), vec![3, 2]);
}
#[test]
fn missing_and_repeated_column_identities_are_rejected() {
let columns = vec!["id".into()];
assert!(SqlExportProjection::resolve(&columns, Some(&[selection(0, "id", 1)]))
.unwrap_err()
.contains("not found"));
assert!(SqlExportProjection::resolve(&columns, Some(&[selection(0, "name", 0)]))
.unwrap_err()
.contains("not found"));
assert!(SqlExportProjection::resolve(&columns, Some(&[selection(0, "id", 0), selection(1, "id", 0)]))
.unwrap_err()
.contains("more than once"));
}
#[test]
fn insert_projection_aligns_types_extras_spatial_data_and_values() {
let columns = vec!["id".into(), "shape".into(), "blob".into(), "optional".into()];
let projection = SqlExportProjection::resolve(
&columns,
Some(&[selection(2, "blob", 0), selection(1, "shape", 0), selection(3, "optional", 0)]),
)
.unwrap();
let options = BuildExportInsertStatementsOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
schema: None,
table_name: Some("items".into()),
qualified_table_name: None,
columns: columns.clone(),
column_types: vec![Some("int".into()), Some("geometry".into()), Some("blob".into()), Some("text".into())],
column_extras: vec![Some("auto_increment".into()), None, None, None],
spatial_columns: vec![SpatialColumn { column_index: 1, srid: Some(4326) }],
spatial_values: vec![vec![None, Some(4326), None, None]],
rows: vec![vec![json!(1), json!("POINT(1 2)"), json!([0, 255]), Value::Null]],
batch_size: Some(100),
};
let projected = projection.project_insert_options(options);
assert_eq!(columns, vec!["id", "shape", "blob", "optional"]);
assert_eq!(projected.columns, vec!["blob", "shape", "optional"]);
assert_eq!(projected.column_types, vec![Some("blob".into()), Some("geometry".into()), Some("text".into())]);
assert_eq!(projected.column_extras, vec![None, None, None]);
assert_eq!(projected.spatial_columns[0].column_index, 1);
assert_eq!(projected.spatial_values, vec![vec![None, Some(4326), None]]);
assert_eq!(projected.rows, vec![vec![json!([0, 255]), json!("POINT(1 2)"), Value::Null]]);
}
#[test]
fn wide_duplicate_selection_preserves_every_position() {
let columns = vec!["value".to_string(); 10000];
let selected: Vec<_> = (0..columns.len()).map(|index| selection(index, "value", index)).collect();
let projection = SqlExportProjection::resolve(&columns, Some(&selected)).unwrap();
let values: Vec<_> = (0..columns.len()).collect();
assert_eq!(projection.project_owned(values.clone()), values);
}
}
fn is_postgres_tsvector_export_column(database_type: Option<DatabaseType>, column_type: Option<&str>) -> bool {
database_type == Some(DatabaseType::Postgres)
&& column_type
+205 -42
View File
@@ -16,7 +16,8 @@ use crate::csv_export::{
};
pub use crate::database_export::ExportStatus;
use crate::database_export::{
build_export_insert_statements_excluding, is_export_cancelled, BuildExportInsertStatementsOptions, SqlInsertMode,
build_export_insert_statements_excluding, is_export_cancelled, BuildExportInsertStatementsOptions,
SqlExportProjection, SqlInsertMode,
};
use crate::models::connection::DatabaseType;
use crate::query::{
@@ -112,6 +113,8 @@ pub struct QueryResultExportRequest {
/// Frontend sends these in original full-query column order.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub export_column_types: Option<Vec<Option<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_columns: Option<Vec<crate::types::SqlExportColumnSelection>>,
/// Column EXTRA metadata (for example SQL Server/Dameng `identity`) for SQL
/// INSERT export, sent by the frontend from the result's table metadata.
/// Without it the exported INSERT writes identity values without
@@ -296,19 +299,29 @@ fn stream_export_was_cancelled(error: &str, token_cancelled: bool, export_cancel
/// Missing, `None`, or empty overrides infer only MySQL spatial types, which
/// preserves the historical literal formatting of other database types.
fn sql_insert_column_types(request: &QueryResultExportRequest, column_types: &[String]) -> Vec<Option<String>> {
resolve_sql_insert_column_types(
request.database_type,
column_types,
request.export_column_types.as_deref().unwrap_or_default(),
)
}
fn resolve_sql_insert_column_types(
database_type: DatabaseType,
column_types: &[String],
overrides: &[Option<String>],
) -> Vec<Option<String>> {
column_types
.iter()
.enumerate()
.map(|(index, inferred)| {
request
.export_column_types
.as_ref()
.and_then(|overrides| overrides.get(index))
overrides
.get(index)
.and_then(|override_type| override_type.as_deref())
.filter(|override_type| !override_type.is_empty())
.map(str::to_string)
.or_else(|| {
(request.database_type == DatabaseType::Mysql
(database_type == DatabaseType::Mysql
&& crate::database_export::is_mysql_spatial_export_type(inferred))
.then(|| inferred.clone())
})
@@ -337,6 +350,7 @@ struct SqlInsertWriter {
schema: Option<String>,
table_name: String,
identifier_quote: Option<String>,
projection: Option<SqlExportProjection>,
/// 生成 INSERT 时需要排除的列名,来自请求里的“不含主键”设置。
exclude_columns: Vec<String>,
}
@@ -426,6 +440,7 @@ impl SqlInsertWriter {
schema: request.schema.clone(),
table_name,
identifier_quote: request.identifier_quote.clone(),
projection: None,
exclude_columns: if request.exclude_primary_keys { request.primary_keys.clone() } else { Vec::new() },
})
}
@@ -439,18 +454,42 @@ impl SqlInsertWriter {
result_column_types: &[String],
spatial_columns: &[SpatialColumn],
request: &QueryResultExportRequest,
) {
self.column_types = sql_insert_column_types(request, result_column_types);
) -> Result<(), String> {
let projection = SqlExportProjection::resolve(&columns, request.selected_columns.as_deref())?;
self.column_types = if request.selected_columns.is_some() {
resolve_sql_insert_column_types(
request.database_type,
&projection.project(result_column_types),
&projection.project_request(request.export_column_types.as_deref().unwrap_or_default()),
)
} else {
sql_insert_column_types(request, result_column_types)
};
// Column extras come from the request and align with the result columns the
// same way `export_column_types` does; missing entries simply mean "unknown".
self.column_extras = request.export_column_extras.clone().unwrap_or_default();
self.spatial_columns = spatial_columns.to_vec();
self.columns = columns;
self.column_extras = projection.project_request(request.export_column_extras.as_deref().unwrap_or_default());
self.spatial_columns = projection.project_spatial_columns(spatial_columns);
self.columns = projection.project(&columns);
self.projection = request.selected_columns.as_ref().map(|_| projection);
Ok(())
}
fn write_row(&mut self, row: Vec<Value>, spatial_values: Option<Vec<Option<u32>>>) -> Result<(), String> {
let row = match &self.projection {
Some(projection) => projection.project_owned(row),
None => row,
};
let spatial_values = spatial_values.unwrap_or_default();
let spatial_values = if spatial_values.is_empty() {
spatial_values
} else {
match &self.projection {
Some(projection) => projection.project_owned(spatial_values),
None => spatial_values,
}
};
self.pending_rows.push(row);
self.pending_spatial_values.push(spatial_values.unwrap_or_default());
self.pending_spatial_values.push(spatial_values);
if self.insert_mode.flush_each_row() || self.pending_rows.len() >= SQL_INSERT_BATCH_SIZE {
self.flush_batch()?;
}
@@ -647,7 +686,20 @@ fn single_execution_row_bound(request: &QueryResultExportRequest) -> Option<usiz
/// page payload, so later pages may reorder, add, or drop columns; values must
/// follow their column name instead of their position in the page.
fn export_column_remap(page_columns: &[String], header_columns: &[String]) -> Vec<Option<usize>> {
header_columns.iter().map(|column| page_columns.iter().position(|page_column| page_column == column)).collect()
let mut occurrences = std::collections::HashMap::<&str, usize>::new();
let mut indexes_by_name = std::collections::HashMap::<&str, Vec<usize>>::new();
for (index, name) in page_columns.iter().enumerate() {
indexes_by_name.entry(name.as_str()).or_default().push(index);
}
header_columns
.iter()
.map(|column| {
let occurrence = occurrences.entry(column.as_str()).or_default();
let index = indexes_by_name.get(column.as_str()).and_then(|indexes| indexes.get(*occurrence)).copied();
*occurrence += 1;
index
})
.collect()
}
/// Reorders one page row (or a page-level cell list such as column types) into
@@ -1010,7 +1062,7 @@ async fn export_query_result_core_inner(
columns = result.columns.clone();
column_types = result.column_types.clone();
if let Some(writer) = sql_writer.as_mut() {
writer.set_columns(columns.clone(), &column_types, &result.spatial_columns, request);
writer.set_columns(columns.clone(), &column_types, &result.spatial_columns, request)?;
}
column_types.clone()
} else if result.columns == columns {
@@ -1247,7 +1299,7 @@ async fn try_export_postgres_query_result_stream(
columns = stream_columns;
temporal_column_types = column_types.clone();
if let Some(writer) = sql_writer.as_mut() {
writer.set_columns(columns.clone(), &column_types, &[], request);
writer.set_columns(columns.clone(), &column_types, &[], request)?;
} else if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns, request.csv_quote_mode);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -1493,7 +1545,7 @@ async fn try_export_mysql_query_result_stream(
columns = stream_columns;
temporal_column_types = column_types.clone();
if let Some(writer) = sql_writer.as_mut() {
writer.set_columns(columns.clone(), &column_types, &[], request);
writer.set_columns(columns.clone(), &column_types, &[], request)?;
} else if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns, request.csv_quote_mode);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -1718,7 +1770,7 @@ async fn try_export_clickhouse_query_result_stream(
columns = stream_columns;
temporal_column_types = column_types.clone();
if let Some(writer) = sql_writer.as_mut() {
writer.set_columns(columns.clone(), &column_types, &[], request);
writer.set_columns(columns.clone(), &column_types, &[], request)?;
} else if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns, request.csv_quote_mode);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -1912,7 +1964,7 @@ async fn try_export_sqlserver_query_result_stream(
columns = stream_columns.to_vec();
temporal_column_types = column_types.to_vec();
if let Some(writer) = sql_writer.as_mut() {
writer.set_columns(columns.clone(), &temporal_column_types, &[], request);
writer.set_columns(columns.clone(), &temporal_column_types, &[], request)?;
} else if let Some(file) = text_file.as_mut() {
let header = format_text_export_header(format, &columns, request.csv_quote_mode);
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
@@ -2131,6 +2183,7 @@ mod tests {
date_time_format: None,
export_table_name: Some("gen_table".to_string()),
export_column_types: Some(vec![Some("int".to_string()), Some("nvarchar(200)".to_string())]),
selected_columns: None,
export_column_extras,
numeric_column_right_align: false,
column_comments: None,
@@ -2151,12 +2204,14 @@ mod tests {
sqlserver_identity_export_request(&file_path, Some(vec![Some("identity(1,1)".to_string()), None]));
let mut writer = SqlInsertWriter::create(&request).expect("create sql insert writer");
writer.set_columns(
vec!["table_id".to_string(), "table_name".to_string()],
&["int".to_string(), "nvarchar(200)".to_string()],
&[],
&request,
);
writer
.set_columns(
vec!["table_id".to_string(), "table_name".to_string()],
&["int".to_string(), "nvarchar(200)".to_string()],
&[],
&request,
)
.expect("set SQL export columns");
writer.write_row(vec![json!(23), json!("t_destype")], None).expect("write export row");
writer.finish().expect("finish export");
@@ -2173,12 +2228,14 @@ mod tests {
let request = sqlserver_identity_export_request(&file_path, None);
let mut writer = SqlInsertWriter::create(&request).expect("create sql insert writer");
writer.set_columns(
vec!["table_id".to_string(), "table_name".to_string()],
&["int".to_string(), "nvarchar(200)".to_string()],
&[],
&request,
);
writer
.set_columns(
vec!["table_id".to_string(), "table_name".to_string()],
&["int".to_string(), "nvarchar(200)".to_string()],
&[],
&request,
)
.expect("set SQL export columns");
writer.write_row(vec![json!(23), json!("t_destype")], None).expect("write export row");
writer.finish().expect("finish export");
@@ -2216,6 +2273,7 @@ mod tests {
date_time_format: None,
export_table_name: Some("users".to_string()),
export_column_types: None,
selected_columns: None,
export_column_extras: None,
numeric_column_right_align: false,
column_comments: None,
@@ -2228,12 +2286,14 @@ mod tests {
};
let mut writer = SqlInsertWriter::create(&request).expect("create sql insert writer");
writer.set_columns(
vec!["id".to_string(), "name".to_string()],
&["integer".to_string(), "text".to_string()],
&[],
&request,
);
writer
.set_columns(
vec!["id".to_string(), "name".to_string()],
&["integer".to_string(), "text".to_string()],
&[],
&request,
)
.expect("set SQL export columns");
writer.write_row(vec![json!(1), json!("Ada")], None).expect("write export row");
writer.finish().expect("finish export");
@@ -2299,6 +2359,7 @@ mod tests {
csv_quote_mode: CsvQuoteMode::All,
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
numeric_column_right_align: false,
column_comments: None,
@@ -2319,12 +2380,14 @@ mod tests {
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
.set_columns(
vec!["id".to_string(), "name".to_string()],
&["int".to_string(), "text".to_string()],
&[],
&req,
)
.expect("set SQL export columns");
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");
@@ -2341,6 +2404,106 @@ mod tests {
assert_eq!(decoded.insert_mode, SqlInsertMode::Batch);
}
#[test]
fn sql_export_column_selection_is_optional_and_round_trips() {
let mut serialized = serde_json::to_value(request("sql", None, None)).unwrap();
serialized.as_object_mut().unwrap().remove("selectedColumns");
let mut decoded: QueryResultExportRequest = serde_json::from_value(serialized).unwrap();
assert!(decoded.selected_columns.is_none());
decoded.selected_columns = Some(vec![crate::types::SqlExportColumnSelection {
source_index: 2,
name: "id".into(),
name_occurrence: 1,
}]);
let encoded = serde_json::to_value(&decoded).unwrap();
assert_eq!(encoded["selectedColumns"], json!([{ "sourceIndex": 2, "name": "id", "nameOccurrence": 1 }]));
let round_trip: QueryResultExportRequest = serde_json::from_value(encoded).unwrap();
assert_eq!(round_trip.selected_columns.unwrap()[0].name_occurrence, 1);
}
#[test]
fn sql_insert_writer_projects_duplicate_columns_and_rows() {
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("selected.sql");
let mut request = request("sql", None, None);
request.file_path = destination.to_string_lossy().into_owned();
request.export_column_types = Some(vec![Some("int".into()), Some("text".into()), Some("bigint".into())]);
request.selected_columns = Some(vec![crate::types::SqlExportColumnSelection {
source_index: 2,
name: "id".into(),
name_occurrence: 1,
}]);
let mut writer = SqlInsertWriter::create(&request).unwrap();
writer
.set_columns(
vec!["id".into(), "name".into(), "id".into()],
&["int".into(), "text".into(), "bigint".into()],
&[],
&request,
)
.unwrap();
assert_eq!(writer.column_types, vec![Some("bigint".into())]);
writer.write_row(vec![json!(1), json!("Ada"), json!(20)], None).unwrap();
writer.write_row(vec![json!(2), json!("Lin"), Value::Null], None).unwrap();
writer.finish().unwrap();
let output = std::fs::read_to_string(destination).unwrap();
assert!(output.contains("(\"id\") VALUES\n(20),\n(NULL);"), "{output}");
assert!(!output.contains("name"));
}
#[test]
fn sql_insert_writer_aligns_request_types_and_identity_after_header_reorder() {
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("identity.sql");
let mut request =
sqlserver_identity_export_request(&destination, Some(vec![Some("identity(1,1)".into()), None]));
request.selected_columns = Some(vec![crate::types::SqlExportColumnSelection {
source_index: 0,
name: "table_id".into(),
name_occurrence: 0,
}]);
let mut writer = SqlInsertWriter::create(&request).unwrap();
writer
.set_columns(
vec!["table_name".into(), "table_id".into()],
&["nvarchar".into(), "int".into()],
&[],
&request,
)
.unwrap();
assert_eq!(writer.column_types, vec![Some("int".into())]);
assert_eq!(writer.column_extras, vec![Some("identity(1,1)".into())]);
writer.write_row(vec![json!("Ada"), json!(23)], None).unwrap();
writer.finish().unwrap();
let output = std::fs::read_to_string(destination).unwrap();
assert!(output.contains("SET IDENTITY_INSERT [dbo].[gen_table] ON;"));
assert!(output.contains("([table_id]) VALUES (23);"));
assert!(!output.contains("table_name"));
}
#[test]
fn sql_insert_writer_rejects_empty_selection_without_replacing_destination() {
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("preserved.sql");
std::fs::write(&destination, "original").unwrap();
let mut request = sqlserver_identity_export_request(&destination, None);
request.selected_columns = Some(Vec::new());
let mut writer = SqlInsertWriter::create(&request).unwrap();
assert!(writer.set_columns(vec!["id".into()], &["int".into()], &[], &request).is_err());
drop(writer);
assert_eq!(std::fs::read_to_string(destination).unwrap(), "original");
}
#[test]
fn export_column_remap_keeps_duplicate_occurrences_across_pages() {
let remap =
export_column_remap(&["id".into(), "id".into(), "name".into()], &["id".into(), "name".into(), "id".into()]);
assert_eq!(
realign_page_cells(&remap, vec![json!(1), json!(2), json!("Ada")]),
vec![json!(1), json!("Ada"), json!(2)]
);
}
#[test]
fn sql_insert_writer_uses_one_batched_statement_by_default() {
let output = rendered_sql_insert_output(SqlInsertMode::Batch);
+77 -11
View File
@@ -22,7 +22,7 @@ use crate::data_grid_sql::extra_is_auto_generated;
pub use crate::database_export::ExportStatus;
use crate::database_export::{
build_export_insert_statements_excluding_with_dialect, is_export_cancelled, is_internal_export_column,
BuildExportInsertStatementsOptions, SqlInsertDialect, SqlInsertMode,
BuildExportInsertStatementsOptions, SqlExportProjection, SqlInsertDialect, SqlInsertMode,
};
use crate::db::agent_driver::AgentTableReadStartParams;
use crate::models::connection::DatabaseType;
@@ -89,6 +89,8 @@ pub struct TableExportRequest {
pub csv_quote_mode: CsvQuoteMode,
#[serde(default)]
pub columns: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_columns: Option<Vec<crate::types::SqlExportColumnSelection>>,
#[serde(default)]
pub column_types: Option<Vec<Option<String>>>,
/// Column EXTRA metadata for `columns`, supplied by the data grid. SQL
@@ -1376,6 +1378,7 @@ async fn try_export_native_table_stream(
}
"sql" => {
let mut file = create_table_export_sql_writer(request)?;
let projection = SqlExportProjection::resolve(col_names, request.selected_columns.as_deref())?;
let mut pending_rows: Vec<Vec<Value>> = Vec::new();
let mut wrote_statements = false;
let mut flush_pending =
@@ -1384,7 +1387,7 @@ async fn try_export_native_table_stream(
return Ok(());
}
let statements = build_export_insert_statements_excluding_with_dialect(
BuildExportInsertStatementsOptions {
projection.project_insert_options(BuildExportInsertStatementsOptions {
database_type: Some(*db_type),
identifier_quote: request.identifier_quote.clone(),
schema: request.schema.clone(),
@@ -1397,7 +1400,7 @@ async fn try_export_native_table_stream(
spatial_values: Vec::new(),
rows: std::mem::take(pending_rows),
batch_size: Some(request.insert_mode.batch_size(SQL_INSERT_BATCH_SIZE)),
},
}),
&sql_export_excluded_columns(request, primary_keys, col_names, column_extras),
request.insert_dialect,
)?;
@@ -1606,10 +1609,18 @@ async fn export_table_data_core_inner(
(col_names, column_types, column_extras, primary_keys)
};
let sql_projection = if request.format.eq_ignore_ascii_case("sql") {
Some(SqlExportProjection::resolve(&col_names, request.selected_columns.as_deref())?)
} else {
None
};
if col_names.is_empty() {
return Err("No columns found for table".to_string());
}
ensure_sql_insert_export_types_supported(&request.format, &column_types)?;
if let Some(projection) = &sql_projection {
ensure_sql_insert_export_types_supported(&request.format, &projection.project(&column_types))?;
}
let query_col_names = table_export_query_columns(request, &sql_context, &col_names)?;
// Use keyset pagination when all PKs are in the selected (filtered) columns.
@@ -2190,6 +2201,7 @@ async fn export_table_data_core_inner(
}
"sql" => {
let mut wrote_statements = false;
let projection = sql_projection.as_ref().ok_or_else(|| "Missing SQL export projection".to_string())?;
loop {
if is_export_cancelled(&request.export_id).await {
@@ -2234,7 +2246,7 @@ async fn export_table_data_core_inner(
}
let statements = build_export_insert_statements_excluding_with_dialect(
BuildExportInsertStatementsOptions {
projection.project_insert_options(BuildExportInsertStatementsOptions {
database_type: Some(db_type),
identifier_quote: request.identifier_quote.clone(),
schema: request.schema.clone(),
@@ -2247,7 +2259,7 @@ async fn export_table_data_core_inner(
spatial_values: result.spatial_values.clone(),
rows: result.rows.clone(),
batch_size: Some(request.insert_mode.batch_size(SQL_INSERT_BATCH_SIZE)),
},
}),
&sql_export_excluded_columns(request, &primary_keys, &col_names, &column_extras),
request.insert_dialect,
)?;
@@ -2452,6 +2464,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: Some(vec!["id".to_string(), "name".to_string()]),
selected_columns: None,
column_types: Some(vec![Some("INTEGER".to_string()), Some("VARCHAR".to_string())]),
column_extras: None,
primary_keys: Some(vec!["id".to_string()]),
@@ -2620,6 +2633,7 @@ mod tests {
csv_quote_mode: CsvQuoteMode::All,
exclude_primary_keys: false,
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -2787,6 +2801,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -2849,6 +2864,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -2889,6 +2905,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -2924,6 +2941,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -2967,6 +2985,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -3022,6 +3041,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -3071,6 +3091,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -3148,6 +3169,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -3207,6 +3229,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -3268,6 +3291,7 @@ mod tests {
insert_mode: Default::default(),
insert_dialect: Default::default(),
columns: None,
selected_columns: None,
column_types: None,
column_extras: None,
primary_keys: None,
@@ -3441,7 +3465,7 @@ mod tests {
.enable_all()
.build()
.expect("build table export plain SQL test runtime")
.block_on(run_external_driver_sql_export_without_split_writes_plain_sql());
.block_on(run_external_driver_sql_export_without_split_writes_plain_sql(false));
})
.expect("spawn table export plain SQL test thread");
if let Err(panic) = handle.join() {
@@ -3450,7 +3474,7 @@ mod tests {
}
#[cfg(unix)]
async fn run_external_driver_sql_export_without_split_writes_plain_sql() {
async fn run_external_driver_sql_export_without_split_writes_plain_sql(select_name: bool) {
let fixture = external_driver_export_fixture(
r#" case "$line" in
*'"method":"executeQueryPage"'*)
@@ -3467,6 +3491,13 @@ mod tests {
let mut request = fixture.request.clone();
request.format = "sql".to_string();
request.file_path = output.to_string_lossy().into_owned();
if select_name {
request.selected_columns = Some(vec![crate::types::SqlExportColumnSelection {
source_index: 1,
name: "name".into(),
name_occurrence: 0,
}]);
}
let terminal = Arc::new(std::sync::Mutex::new(None::<ExportStatus>));
let captured = terminal.clone();
@@ -3481,6 +3512,10 @@ mod tests {
assert_eq!(sql.matches("INSERT INTO").count(), 1, "both rows should be exported: {sql}");
assert!(sql.contains("Ada"));
assert!(sql.contains("Grace"));
if select_name {
assert!(sql.contains("(name) VALUES"), "{sql}");
assert!(!sql.contains("(id,"), "{sql}");
}
assert!(sql.ends_with(";\n"));
// Without `split_max_mb` the fallback arm must keep producing a plain
// SQL file, not a zip archive.
@@ -3504,7 +3539,7 @@ mod tests {
.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());
.block_on(run_external_driver_sql_export_honors_split_max_mb_into_zip_parts(false));
})
.expect("spawn table export split SQL test thread");
if let Err(panic) = handle.join() {
@@ -3513,7 +3548,7 @@ mod tests {
}
#[cfg(unix)]
async fn run_external_driver_sql_export_honors_split_max_mb_into_zip_parts() {
async fn run_external_driver_sql_export_honors_split_max_mb_into_zip_parts(select_name: bool) {
// 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
@@ -3548,6 +3583,13 @@ esac"#,
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);
if select_name {
request.selected_columns = Some(vec![crate::types::SqlExportColumnSelection {
source_index: 1,
name: "name".into(),
name_occurrence: 0,
}]);
}
let terminal = Arc::new(std::sync::Mutex::new(None::<ExportStatus>));
let captured = terminal.clone();
@@ -3608,7 +3650,13 @@ esac"#,
// 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);
if select_name {
assert!(combined.contains("(name) VALUES"));
assert!(!combined.contains("(id,"));
assert_eq!(combined.matches("('x").count(), 300);
} else {
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());
@@ -3618,6 +3666,24 @@ esac"#,
cleanup_external_driver_export_fixture(fixture);
}
#[cfg(unix)]
#[test]
fn external_driver_sql_export_selected_columns_preserve_cursor_pages_and_zip_parts() {
let handle = std::thread::Builder::new()
.name("table-export-selected-columns".into())
.stack_size(8 * 1024 * 1024)
.spawn(|| {
tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
run_external_driver_sql_export_without_split_writes_plain_sql(true).await;
run_external_driver_sql_export_honors_split_max_mb_into_zip_parts(true).await;
});
})
.unwrap();
if let Err(panic) = handle.join() {
std::panic::resume_unwind(panic);
}
}
#[cfg(unix)]
#[tokio::test]
async fn external_driver_table_export_does_not_repeat_legacy_one_shot_results() {
@@ -104,6 +104,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
+3
View File
@@ -479,6 +479,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -573,6 +574,7 @@ async fn live_mysql_csv_temporal_export_round_trip_preserves_dbx_force_text_valu
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -691,6 +693,7 @@ async fn live_mysql_xlsx_export_can_outlive_query_timeout_while_rows_keep_arrivi
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -152,6 +152,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() {
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -239,6 +240,7 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() {
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -315,6 +317,7 @@ async fn live_postgres_numeric_xlsx_ignores_fractional_trailing_zeros() {
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -396,6 +399,7 @@ async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() {
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -479,6 +483,7 @@ async fn live_postgres_xlsx_export_can_outlive_query_timeout_while_rows_keep_arr
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -553,6 +558,7 @@ async fn live_postgres_stream_still_times_out_without_progress_and_recovers() {
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -1217,6 +1217,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() {
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
@@ -131,6 +131,7 @@ async fn live_sqlserver_xlsx_export_can_outlive_query_timeout_while_rows_keep_ar
csv_quote_mode: Default::default(),
export_table_name: None,
export_column_types: None,
selected_columns: None,
export_column_extras: None,
column_comments: None,
auto_filter: None,
+14
View File
@@ -408,6 +408,20 @@ pub struct SpatialColumn {
pub srid: Option<u32>,
}
/// Stable identity for one column selected for SQL INSERT export.
///
/// `source_index` preserves duplicate result labels. `name` and
/// `name_occurrence` let paginated exports recover the same identity when a
/// driver reports later-page metadata in a different order.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqlExportColumnSelection {
pub source_index: usize,
pub name: String,
#[serde(default)]
pub name_occurrence: usize,
}
#[derive(Debug, Default)]
pub struct SpatialColumnBuilder {
// column_index -> first non-null srid seen (sticky once set)