From 7eda73fbd82de5e4b64aaa86eefc1284f1cd011e Mon Sep 17 00:00:00 2001 From: Jamie Date: Sat, 25 Jul 2026 08:47:51 +0800 Subject: [PATCH] feat(grid): copy column names with quoting options --- apps/desktop/src/components/grid/DataGrid.vue | 42 +++++++++-- .../grid/DataGridCopyColumnNamesDialog.vue | 75 +++++++++++++++++++ .../grid/__tests__/DataGridSurfaces.spec.ts | 53 +++++++++++++ .../src/composables/useDataGridExport.ts | 6 -- apps/desktop/src/i18n/locales/en.ts | 5 ++ apps/desktop/src/i18n/locales/es.ts | 5 ++ apps/desktop/src/i18n/locales/it.ts | 5 ++ apps/desktop/src/i18n/locales/ja.ts | 5 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 5 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 5 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 5 ++ .../dataGrid/dataGridColumnNameCopy.spec.ts | 75 +++++++++++++++++++ .../__tests__/table/tableSelectSql.spec.ts | 5 ++ .../lib/dataGrid/dataGridColumnNameCopy.ts | 55 ++++++++++++++ apps/desktop/src/lib/table/tableSelectSql.ts | 1 + 15 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/grid/DataGridCopyColumnNamesDialog.vue create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 343c3efe3..0a8d63d15 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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(null); const contextHeaderVisibleColIdx = ref(null); const bulkEditDialogOpen = ref(false); const bulkEditValue = ref(""); +const copyColumnNamesDialogOpen = ref(false); +const copyColumnNamesDialogColumns = ref([]); 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(() => { 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(() => { }, 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(() => { + + diff --git a/apps/desktop/src/components/grid/DataGridCopyColumnNamesDialog.vue b/apps/desktop/src/components/grid/DataGridCopyColumnNamesDialog.vue new file mode 100644 index 000000000..901116edc --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridCopyColumnNamesDialog.vue @@ -0,0 +1,75 @@ + + + diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index 47d91f21f..51e9b2a19 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -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) { + 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"); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 385f19bda..d2b937430 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -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 { 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, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a74b700d8..2e45e6b26 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 1b2a13014..d2058003e 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 16ec015ee..b2830f1eb 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6ea14ae25..f5120a455 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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としてコピー", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b2b1a7d32..4cb468c01 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 27641731c..cec6ba98f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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 语句", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3e0d80536..28a09e44d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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 語句", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts new file mode 100644 index 000000000..2c06f4f47 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts @@ -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(); + 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"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts index 88a951e59..1fa39f683 100644 --- a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts @@ -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]"); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts new file mode 100644 index 000000000..2730f6762 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts @@ -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 = { + 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 = { + 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(["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]); +// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。 +const UNQUOTABLE_DATABASE_TYPES = new Set(["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); +} diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index 7ad8c7f7d..c8c1f1c57 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -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;