feat(grid): copy column names with quoting options
This commit is contained in:
parent
d341581ff0
commit
7eda73fbd8
|
|
@ -180,6 +180,7 @@ import { useToast } from "@/composables/useToast";
|
|||
import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport";
|
||||
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
|
||||
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
||||
|
|
@ -229,6 +230,7 @@ const DataGridCellDetailDialog = defineAsyncComponent(() => import("@/components
|
|||
const DataGridMongoJsonPreview = defineAsyncComponent(() => import("@/components/grid/DataGridMongoJsonPreview.vue"));
|
||||
const DataGridDetailDialogs = defineAsyncComponent(() => import("@/components/grid/DataGridDetailDialogs.vue"));
|
||||
const DataGridBulkEditDialog = defineAsyncComponent(() => import("@/components/grid/DataGridBulkEditDialog.vue"));
|
||||
const DataGridCopyColumnNamesDialog = defineAsyncComponent(() => import("@/components/grid/DataGridCopyColumnNamesDialog.vue"));
|
||||
const ExportProgressDialog = defineAsyncComponent(() => import("@/components/export/ExportProgressDialog.vue"));
|
||||
const FORMATTED_JSON_EDIT_WARNING_COUNT_STORAGE_KEY = "dbx-cell-detail-formatted-json-edit-warning-count";
|
||||
const FORMATTED_JSON_EDIT_WARNING_MAX_COUNT = 3;
|
||||
|
|
@ -551,6 +553,8 @@ const contextHeaderColumnIndex = ref<number | null>(null);
|
|||
const contextHeaderVisibleColIdx = ref<number | null>(null);
|
||||
const bulkEditDialogOpen = ref(false);
|
||||
const bulkEditValue = ref("");
|
||||
const copyColumnNamesDialogOpen = ref(false);
|
||||
const copyColumnNamesDialogColumns = ref<string[]>([]);
|
||||
const generateIncrementDialogOpen = ref(false);
|
||||
const generateIncrementStartValue = ref("1");
|
||||
const generateIncrementTarget = ref<"selection" | "detail">("selection");
|
||||
|
|
@ -572,6 +576,7 @@ const imagePreviewOpen = ref(false);
|
|||
const imagePreviewSrc = ref("");
|
||||
const imagePreviewTitle = ref("");
|
||||
const bulkEditDialogMounted = useDataGridAsyncSurface(bulkEditDialogOpen);
|
||||
const copyColumnNamesDialogMounted = useDataGridAsyncSurface(copyColumnNamesDialogOpen);
|
||||
const cellDetailDialogMounted = useDataGridAsyncSurface(cellDetailDialogOpen);
|
||||
const detailDialogsMounted = useDataGridAsyncSurface(computed(() => rowDetailDialogOpen.value || columnDetailDialogOpen.value));
|
||||
const imagePreviewMounted = useDataGridAsyncSurface(imagePreviewOpen);
|
||||
|
|
@ -5079,7 +5084,6 @@ const {
|
|||
canCopySelectionAsInsert,
|
||||
copySelectedRowsTsv,
|
||||
copySelectedRowsTsvWithHeaders,
|
||||
copyColumnNames,
|
||||
exportCsv,
|
||||
exportCurrentPageCsv,
|
||||
exportJson,
|
||||
|
|
@ -6480,6 +6484,32 @@ async function copyHeaderColumn() {
|
|||
await copyText(contextHeaderColumn.value);
|
||||
}
|
||||
|
||||
// 显式多选的列名(按显示顺序);仅用于表头「复制选中列名」
|
||||
const selectedColumnNamesForCopy = computed(() => {
|
||||
return [...selectedColumnIndexes.value]
|
||||
.sort((a, b) => a - b)
|
||||
.map((index) => visibleColumns.value[index])
|
||||
.filter((name): name is string => name !== undefined);
|
||||
});
|
||||
|
||||
function openCopyColumnNamesDialog(names: string[]) {
|
||||
if (names.length === 0) return;
|
||||
copyColumnNamesDialogColumns.value = names;
|
||||
copyColumnNamesDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openCopyAllColumnNamesDialog() {
|
||||
openCopyColumnNamesDialog(columnNamesForCopy(props.result.columns, visibleColumns.value, "all"));
|
||||
}
|
||||
|
||||
function copyHeaderColumnOrSelected() {
|
||||
if (selectedColumnNamesForCopy.value.length > 1) {
|
||||
openCopyColumnNamesDialog(selectedColumnNamesForCopy.value);
|
||||
return;
|
||||
}
|
||||
void copyHeaderColumn();
|
||||
}
|
||||
|
||||
const canCopyAlterColumnSql = computed(() => {
|
||||
if (!contextHeaderColumn.value || !props.tableMeta?.columns) return false;
|
||||
if (tableStructureCapabilities.value.alterStrategy !== "direct") return false;
|
||||
|
|
@ -7425,7 +7455,7 @@ function copySubmenu(): ContextMenuItem {
|
|||
items.push({ label: labels.update, action: copyRowAsUpdate });
|
||||
}
|
||||
items.push({ label: t("grid.copyAll"), action: copyAll });
|
||||
items.push({ label: t("grid.copyColumnNames"), action: copyColumnNames });
|
||||
items.push({ label: t("grid.copyColumnNames"), action: openCopyAllColumnNamesDialog });
|
||||
return { label: t("grid.copy"), icon: Copy, children: items };
|
||||
}
|
||||
|
||||
|
|
@ -7514,7 +7544,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
|
||||
hasColumnSelection: hasColumnSelection.value,
|
||||
labels: {
|
||||
copyName: t("grid.copyColumnName"),
|
||||
copyName: selectedColumnNamesForCopy.value.length > 1 ? t("grid.copyColumnNamesSelected", { count: selectedColumnNamesForCopy.value.length }) : t("grid.copyColumnName"),
|
||||
copyNames: t("grid.copyColumnNames"),
|
||||
details: t("grid.openColumnDetailsDialog"),
|
||||
copyAlterSql: t("grid.copyAlterColumnSql"),
|
||||
|
|
@ -7529,8 +7559,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
},
|
||||
icons: { copy: Copy, columnDetails: TableProperties, database: Database, ascending: ArrowUp, descending: ArrowDown, clearSort: Eraser },
|
||||
actions: {
|
||||
copyName: copyHeaderColumn,
|
||||
copyNames: copyColumnNames,
|
||||
copyName: copyHeaderColumnOrSelected,
|
||||
copyNames: openCopyAllColumnNamesDialog,
|
||||
details: openContextColumnDetailDialog,
|
||||
copyAlterSql: copyAlterColumnSql,
|
||||
sort: applyContextSort,
|
||||
|
|
@ -9117,6 +9147,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
|
||||
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
|
||||
|
||||
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" @copy="copyText" />
|
||||
|
||||
<Dialog v-model:open="generateIncrementDialogOpen">
|
||||
<DialogContent class="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, formatColumnNamesForCopy, isColumnNameCopySeparator, loadColumnNameCopySeparator, saveColumnNameCopySeparator, supportsColumnNameQuoting, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const props = defineProps<{ columnNames: string[]; databaseType?: DatabaseType }>();
|
||||
const emit = defineEmits<{ copy: [text: string] }>();
|
||||
|
||||
const separator = ref<ColumnNameCopySeparator>(loadColumnNameCopySeparator());
|
||||
const quote = ref(false);
|
||||
|
||||
// 每次打开时重新读取上次分隔符选择;转义选项每次默认关闭
|
||||
watch(open, (value) => {
|
||||
if (!value) return;
|
||||
separator.value = loadColumnNameCopySeparator();
|
||||
quote.value = false;
|
||||
});
|
||||
|
||||
const showQuoteOption = computed(() => supportsColumnNameQuoting(props.databaseType));
|
||||
|
||||
const previewText = computed(() => formatColumnNamesForCopy(props.columnNames, { separator: separator.value, quote: quote.value, databaseType: props.databaseType }));
|
||||
|
||||
function onSeparatorChange(value: unknown) {
|
||||
if (isColumnNameCopySeparator(value)) separator.value = value;
|
||||
}
|
||||
|
||||
function confirmCopy() {
|
||||
saveColumnNameCopySeparator(separator.value);
|
||||
emit("copy", previewText.value);
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[440px]">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("grid.copyColumnNamesTitle", { count: columnNames.length }) }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="shrink-0 text-sm">{{ t("grid.copyColumnNamesSeparator") }}</Label>
|
||||
<Select :model-value="separator" @update:model-value="onSeparatorChange">
|
||||
<SelectTrigger class="h-8 w-44 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectItem v-for="option in COLUMN_NAME_COPY_SEPARATOR_OPTIONS" :key="option" :value="option" class="dbx-data-grid-value-font text-xs">{{ COLUMN_NAME_COPY_SEPARATOR_LABELS[option] }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="showQuoteOption" class="flex items-center justify-between gap-3">
|
||||
<Label class="shrink-0 text-sm" for="copy-column-names-quote">{{ t("grid.copyColumnNamesQuote") }}</Label>
|
||||
<Switch id="copy-column-names-quote" v-model="quote" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-sm">{{ t("grid.copyColumnNamesPreview") }}</Label>
|
||||
<pre class="dbx-data-grid-value-font max-h-40 min-h-16 w-full overflow-auto whitespace-pre-wrap break-all rounded-[6px] border border-input bg-muted/30 px-2.5 py-1.5 text-xs" data-copy-column-names-preview>{{ previewText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button
|
||||
><Button @click="confirmCopy">{{ t("grid.copy") }}</Button></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -64,6 +64,8 @@ vi.mock("@/components/ui/select", async () => {
|
|||
return { Select: createPassthroughStub("Select"), SelectContent: createPassthroughStub("SelectContent"), SelectItem: createPassthroughStub("SelectItem"), SelectTrigger: createPassthroughStub("SelectTrigger"), SelectValue: createPassthroughStub("SelectValue") };
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => ({ TabsContent: (await import("./vueHostHarness")).createPassthroughStub("TabsContent") }));
|
||||
vi.mock("@/components/ui/switch", async () => ({ Switch: (await import("./vueHostHarness")).createPassthroughStub("Switch", "button") }));
|
||||
vi.mock("@/components/ui/label", async () => ({ Label: (await import("./vueHostHarness")).createPassthroughStub("Label", "label") }));
|
||||
vi.mock("@/components/ui/LightDropdown.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightDropdown") }));
|
||||
vi.mock("@/components/ui/LightTooltip.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightTooltip") }));
|
||||
vi.mock("@/components/grid/TemporalCellEditor.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("TemporalCellEditor") }));
|
||||
|
|
@ -84,6 +86,7 @@ vi.mock("@/composables/useDataGridCellDetail", async () => {
|
|||
import DataGridCellDetailDialog from "@/components/grid/DataGridCellDetailDialog.vue";
|
||||
import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.vue";
|
||||
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
|
||||
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
|
||||
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
|
||||
|
|
@ -616,3 +619,53 @@ describe("cell detail surfaces", () => {
|
|||
expect(mocks.panelOpenSearch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridCopyColumnNamesDialog", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem("dbx-copy-column-names-separator");
|
||||
});
|
||||
|
||||
function previewText(mounted: ReturnType<typeof mountComponent>) {
|
||||
return hostText(findOne(mounted.root, (node) => node.props["data-copy-column-names-preview"] === ""));
|
||||
}
|
||||
|
||||
it("previews the formatted names and copies with the chosen separator and quoting", async () => {
|
||||
const copy = vi.fn();
|
||||
const openChange = vi.fn();
|
||||
const mounted = mountComponent(DataGridCopyColumnNamesDialog, {
|
||||
open: true,
|
||||
columnNames: ["id", "type"],
|
||||
databaseType: "mysql",
|
||||
onCopy: copy,
|
||||
"onUpdate:open": openChange,
|
||||
});
|
||||
expect(previewText(mounted)).toBe("id\ttype");
|
||||
|
||||
findOne(mounted.root, (node) => node.props["data-stub"] === "Select").props["onUpdate:modelValue"]("comma-newline");
|
||||
findOne(mounted.root, (node) => node.props["data-stub"] === "Switch").props["onUpdate:modelValue"](true);
|
||||
await nextTick();
|
||||
expect(previewText(mounted)).toBe("`id`,\n`type`");
|
||||
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.props["data-stub"] === "Button" && hostText(node) === "grid.copy"),
|
||||
"click",
|
||||
);
|
||||
expect(copy).toHaveBeenCalledWith("`id`,\n`type`");
|
||||
expect(openChange).toHaveBeenCalledWith(false);
|
||||
expect(localStorage.getItem("dbx-copy-column-names-separator")).toBe("comma-newline");
|
||||
});
|
||||
|
||||
it("hides the quote option for non-SQL databases and ignores invalid separators", async () => {
|
||||
const mounted = mountComponent(DataGridCopyColumnNamesDialog, {
|
||||
open: true,
|
||||
columnNames: ["id", "type"],
|
||||
databaseType: "mongodb",
|
||||
onCopy: vi.fn(),
|
||||
});
|
||||
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Switch")).toHaveLength(0);
|
||||
|
||||
findOne(mounted.root, (node) => node.props["data-stub"] === "Select").props["onUpdate:modelValue"]("bogus");
|
||||
await nextTick();
|
||||
expect(previewText(mounted)).toBe("id\ttype");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -799,11 +799,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }, true), { rows, includeHeader: true });
|
||||
}
|
||||
|
||||
async function copyColumnNames() {
|
||||
if (columns.value.length === 0) return;
|
||||
await copyText(columns.value.join("\t"));
|
||||
}
|
||||
|
||||
function rowToJsonObject(item: RowItem): Record<string, unknown> {
|
||||
if (options.databaseType.value === "mongodb" && item.sourceIndex !== undefined) {
|
||||
const original = options.mongoDocuments?.value?.[item.sourceIndex];
|
||||
|
|
@ -1547,7 +1542,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
selectionInsertRowCount,
|
||||
copySelectedRowsTsv,
|
||||
copySelectedRowsTsvWithHeaders,
|
||||
copyColumnNames,
|
||||
exportCsv,
|
||||
exportCurrentPageCsv,
|
||||
exportJson,
|
||||
|
|
|
|||
|
|
@ -935,6 +935,11 @@ export default {
|
|||
copyRow: "Copy Row (JSON)",
|
||||
copyColumnJson: "Copy Column (JSON)",
|
||||
copyColumnNames: "Copy Column Names",
|
||||
copyColumnNamesSelected: "Copy Selected Column Names ({count})",
|
||||
copyColumnNamesTitle: "Copy Column Names ({count})",
|
||||
copyColumnNamesSeparator: "Column Separator",
|
||||
copyColumnNamesQuote: "Quote Column Names",
|
||||
copyColumnNamesPreview: "Preview",
|
||||
copyRowInsert: "Copy as INSERT",
|
||||
copyRowInsertWithoutPrimaryKeys: "Copy as INSERT without Primary Keys",
|
||||
copyRowUpdate: "Copy as UPDATE",
|
||||
|
|
|
|||
|
|
@ -883,6 +883,11 @@ export default withEnglishFallback({
|
|||
copyRow: "Copiar fila (JSON)",
|
||||
copyColumnJson: "Copiar columna (JSON)",
|
||||
copyColumnNames: "Copiar nombres de columnas",
|
||||
copyColumnNamesSelected: "Copiar nombres de columnas seleccionadas ({count})",
|
||||
copyColumnNamesTitle: "Copiar nombres de columnas ({count})",
|
||||
copyColumnNamesSeparator: "Separador de columnas",
|
||||
copyColumnNamesQuote: "Escapar nombres de columnas",
|
||||
copyColumnNamesPreview: "Vista previa",
|
||||
copyRowInsert: "Copiar como INSERT",
|
||||
copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sin claves primarias",
|
||||
copyRowUpdate: "Copiar como UPDATE",
|
||||
|
|
|
|||
|
|
@ -881,6 +881,11 @@ export default withEnglishFallback({
|
|||
copyRow: "Copia Riga (JSON)",
|
||||
copyColumnJson: "Copia Colonna (JSON)",
|
||||
copyColumnNames: "Copia Nomi Colonne",
|
||||
copyColumnNamesSelected: "Copia Nomi Colonne Selezionate ({count})",
|
||||
copyColumnNamesTitle: "Copia Nomi Colonne ({count})",
|
||||
copyColumnNamesSeparator: "Separatore di colonne",
|
||||
copyColumnNamesQuote: "Racchiudi i nomi tra virgolette",
|
||||
copyColumnNamesPreview: "Anteprima",
|
||||
copyRowInsert: "Copia come INSERT",
|
||||
copyRowInsertWithoutPrimaryKeys: "Copia come INSERT senza Chiavi Primarie",
|
||||
copyRowUpdate: "Copia come UPDATE",
|
||||
|
|
|
|||
|
|
@ -880,6 +880,11 @@ export default withEnglishFallback({
|
|||
copyRow: "行をコピー (JSON)",
|
||||
copyColumnJson: "列をコピー (JSON)",
|
||||
copyColumnNames: "すべての列名をコピー",
|
||||
copyColumnNamesSelected: "選択した列名をコピー({count} 列)",
|
||||
copyColumnNamesTitle: "列名をコピー({count} 列)",
|
||||
copyColumnNamesSeparator: "列区切り文字",
|
||||
copyColumnNamesQuote: "列名を引用符で囲む",
|
||||
copyColumnNamesPreview: "プレビュー",
|
||||
copyRowInsert: "INSERTとしてコピー",
|
||||
copyRowInsertWithoutPrimaryKeys: "主キーなしでINSERTとしてコピー",
|
||||
copyRowUpdate: "UPDATEとしてコピー",
|
||||
|
|
|
|||
|
|
@ -883,6 +883,11 @@ export default withEnglishFallback({
|
|||
copyRow: "Copiar Linha (JSON)",
|
||||
copyColumnJson: "Copiar Coluna (JSON)",
|
||||
copyColumnNames: "Copiar Nomes das Colunas",
|
||||
copyColumnNamesSelected: "Copiar Nomes das Colunas Selecionadas ({count})",
|
||||
copyColumnNamesTitle: "Copiar Nomes das Colunas ({count})",
|
||||
copyColumnNamesSeparator: "Separador de colunas",
|
||||
copyColumnNamesQuote: "Escapar nomes das colunas",
|
||||
copyColumnNamesPreview: "Pré-visualização",
|
||||
copyRowInsert: "Copiar como INSERT",
|
||||
copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sem Chaves Primárias",
|
||||
copyRowUpdate: "Copiar como UPDATE",
|
||||
|
|
|
|||
|
|
@ -936,6 +936,11 @@ export default withEnglishFallback({
|
|||
copyRow: "复制行 (JSON)",
|
||||
copyColumnJson: "复制列 (JSON)",
|
||||
copyColumnNames: "复制全部列名",
|
||||
copyColumnNamesSelected: "复制选中列名({count} 列)",
|
||||
copyColumnNamesTitle: "复制列名({count} 列)",
|
||||
copyColumnNamesSeparator: "列分隔符",
|
||||
copyColumnNamesQuote: "转义列名",
|
||||
copyColumnNamesPreview: "预览",
|
||||
copyRowInsert: "复制为 INSERT 语句",
|
||||
copyRowInsertWithoutPrimaryKeys: "复制为 INSERT 语句(不含主键)",
|
||||
copyRowUpdate: "复制为 UPDATE 语句",
|
||||
|
|
|
|||
|
|
@ -882,6 +882,11 @@ export default withEnglishFallback({
|
|||
copyRow: "複製整筆 (JSON)",
|
||||
copyColumnJson: "複製欄 (JSON)",
|
||||
copyColumnNames: "複製全部欄名",
|
||||
copyColumnNamesSelected: "複製選取欄名({count} 欄)",
|
||||
copyColumnNamesTitle: "複製欄名({count} 欄)",
|
||||
copyColumnNamesSeparator: "欄分隔符",
|
||||
copyColumnNamesQuote: "轉義欄名",
|
||||
copyColumnNamesPreview: "預覽",
|
||||
copyRowInsert: "複製為 INSERT 語句",
|
||||
copyRowInsertWithoutPrimaryKeys: "複製為 INSERT 語句(不含主鍵)",
|
||||
copyRowUpdate: "複製為 UPDATE 語句",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { COLUMN_NAME_COPY_SEPARATOR_OPTIONS, columnNamesForCopy, formatColumnNamesForCopy, isColumnNameCopySeparator, loadColumnNameCopySeparator, saveColumnNameCopySeparator, supportsColumnNameQuoting } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
|
||||
describe("dataGridColumnNameCopy", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("joins names with every supported separator", () => {
|
||||
const names = ["id", "type", "order"];
|
||||
expect(formatColumnNamesForCopy(names, { separator: "tab" })).toBe("id\ttype\torder");
|
||||
expect(formatColumnNamesForCopy(names, { separator: "comma" })).toBe("id,type,order");
|
||||
expect(formatColumnNamesForCopy(names, { separator: "newline" })).toBe("id\ntype\norder");
|
||||
expect(formatColumnNamesForCopy(names, { separator: "comma-newline" })).toBe("id,\ntype,\norder");
|
||||
});
|
||||
|
||||
it("quotes names with the database-specific identifier quote", () => {
|
||||
const names = ["type", "order"];
|
||||
expect(formatColumnNamesForCopy(names, { separator: "comma", quote: true, databaseType: "mysql" })).toBe("`type`,`order`");
|
||||
expect(formatColumnNamesForCopy(names, { separator: "comma", quote: true, databaseType: "clickhouse" })).toBe("`type`,`order`");
|
||||
expect(formatColumnNamesForCopy(names, { separator: "comma", quote: true, databaseType: "postgres" })).toBe('"type","order"');
|
||||
expect(formatColumnNamesForCopy(names, { separator: "comma", quote: true, databaseType: "sqlserver" })).toBe("[type],[order]");
|
||||
});
|
||||
|
||||
it("escapes embedded quote characters when quoting", () => {
|
||||
expect(formatColumnNamesForCopy(["a`b"], { separator: "tab", quote: true, databaseType: "mysql" })).toBe("`a``b`");
|
||||
expect(formatColumnNamesForCopy(['a"b'], { separator: "tab", quote: true, databaseType: "postgres" })).toBe('"a""b"');
|
||||
});
|
||||
|
||||
it("ignores the quote flag for databases without SQL identifier quoting", () => {
|
||||
expect(formatColumnNamesForCopy(["type"], { separator: "tab", quote: true, databaseType: "mongodb" })).toBe("type");
|
||||
expect(formatColumnNamesForCopy(["type"], { separator: "tab", quote: true, databaseType: "qdrant" })).toBe("type");
|
||||
expect(formatColumnNamesForCopy(["type"], { separator: "tab", quote: true })).toBe("type");
|
||||
});
|
||||
|
||||
it("reports quoting support only for SQL databases with a usable quote character", () => {
|
||||
expect(supportsColumnNameQuoting("mysql")).toBe(true);
|
||||
expect(supportsColumnNameQuoting("clickhouse")).toBe(true);
|
||||
expect(supportsColumnNameQuoting("postgres")).toBe(true);
|
||||
expect(supportsColumnNameQuoting("mongodb")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("redis")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("elasticsearch")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("qdrant")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("jdbc")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("iotdb")).toBe(false);
|
||||
expect(supportsColumnNameQuoting(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps hidden columns when copying all column names", () => {
|
||||
const allColumns = ["id", "hidden_value", "created_at"];
|
||||
const visibleColumns = ["id", "created_at"];
|
||||
expect(columnNamesForCopy(allColumns, visibleColumns, "all")).toEqual(allColumns);
|
||||
expect(columnNamesForCopy(allColumns, visibleColumns, "visible")).toEqual(visibleColumns);
|
||||
});
|
||||
|
||||
it("validates separator values", () => {
|
||||
for (const option of COLUMN_NAME_COPY_SEPARATOR_OPTIONS) expect(isColumnNameCopySeparator(option)).toBe(true);
|
||||
expect(isColumnNameCopySeparator(";")).toBe(false);
|
||||
expect(isColumnNameCopySeparator(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("persists the separator choice and falls back to tab for missing or invalid values", () => {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
});
|
||||
expect(loadColumnNameCopySeparator()).toBe("tab");
|
||||
saveColumnNameCopySeparator("comma-newline");
|
||||
expect(loadColumnNameCopySeparator()).toBe("comma-newline");
|
||||
store.set("dbx-copy-column-names-separator", "bogus");
|
||||
expect(loadColumnNameCopySeparator()).toBe("tab");
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +39,11 @@ describe("quoteTableIdentifier", () => {
|
|||
expect(quoteTableIdentifier("mysql", "orders")).toBe("`orders`");
|
||||
});
|
||||
|
||||
it("uses BigQuery quoted identifiers and escape sequences", () => {
|
||||
expect(quoteTableIdentifier("bigquery", "order")).toBe("`order`");
|
||||
expect(quoteTableIdentifier("bigquery", "a`b")).toBe("`a\\`b`");
|
||||
});
|
||||
|
||||
it("bracket-quotes sqlserver identifiers", () => {
|
||||
expect(quoteTableIdentifier("sqlserver", "orders")).toBe("[orders]");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import type { DatabaseType } from "@/types/database";
|
||||
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
export type ColumnNameCopySeparator = "tab" | "comma" | "newline" | "comma-newline";
|
||||
|
||||
export const COLUMN_NAME_COPY_SEPARATOR_VALUES: Record<ColumnNameCopySeparator, string> = {
|
||||
tab: "\t",
|
||||
comma: ",",
|
||||
newline: "\n",
|
||||
"comma-newline": ",\n",
|
||||
};
|
||||
|
||||
export const COLUMN_NAME_COPY_SEPARATOR_OPTIONS = Object.keys(COLUMN_NAME_COPY_SEPARATOR_VALUES) as ColumnNameCopySeparator[];
|
||||
|
||||
export const COLUMN_NAME_COPY_SEPARATOR_LABELS: Record<ColumnNameCopySeparator, string> = {
|
||||
tab: "\\t",
|
||||
comma: ",",
|
||||
newline: "\\n",
|
||||
"comma-newline": ",\\n",
|
||||
};
|
||||
|
||||
export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCopySeparator {
|
||||
return typeof value === "string" && value in COLUMN_NAME_COPY_SEPARATOR_VALUES;
|
||||
}
|
||||
|
||||
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]);
|
||||
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
|
||||
const UNQUOTABLE_DATABASE_TYPES = new Set<DatabaseType>(["jdbc", "iotdb"]);
|
||||
|
||||
export function supportsColumnNameQuoting(databaseType?: DatabaseType): boolean {
|
||||
return !!databaseType && !NON_SQL_DATABASE_TYPES.has(databaseType) && !UNQUOTABLE_DATABASE_TYPES.has(databaseType);
|
||||
}
|
||||
|
||||
export function columnNamesForCopy(allColumnNames: readonly string[], visibleColumnNames: readonly string[], scope: "all" | "visible"): string[] {
|
||||
return [...(scope === "all" ? allColumnNames : visibleColumnNames)];
|
||||
}
|
||||
|
||||
export function formatColumnNamesForCopy(names: readonly string[], options: { separator: ColumnNameCopySeparator; quote?: boolean; databaseType?: DatabaseType }): string {
|
||||
const quote = !!options.quote && supportsColumnNameQuoting(options.databaseType);
|
||||
const parts = quote ? names.map((name) => quoteTableIdentifier(options.databaseType, name)) : [...names];
|
||||
return parts.join(COLUMN_NAME_COPY_SEPARATOR_VALUES[options.separator]);
|
||||
}
|
||||
|
||||
const SEPARATOR_STORAGE_KEY = "dbx-copy-column-names-separator";
|
||||
|
||||
export function loadColumnNameCopySeparator(): ColumnNameCopySeparator {
|
||||
const stored = safeLocalStorageGet(SEPARATOR_STORAGE_KEY);
|
||||
return isColumnNameCopySeparator(stored) ? stored : "tab";
|
||||
}
|
||||
|
||||
export function saveColumnNameCopySeparator(separator: ColumnNameCopySeparator) {
|
||||
safeLocalStorageSet(SEPARATOR_STORAGE_KEY, separator);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam
|
|||
// JDBC connections use the driver-reported identifier quote string
|
||||
// (DatabaseMetaData.getIdentifierQuoteString()) — pass through unquoted.
|
||||
if (databaseType === "jdbc") return name;
|
||||
if (databaseType === "bigquery") return `\`${name.replace(/`/g, "\\`")}\``;
|
||||
if (databaseType === "mysql" || databaseType === "clickhouse" || databaseType === "hive" || databaseType === "spark" || databaseType === "databend" || databaseType === "tdengine" || databaseType === "access" || databaseType === "doris" || databaseType === "starrocks")
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
if (databaseType === "informix" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(name)) return name;
|
||||
|
|
|
|||
Loading…
Reference in New Issue