feat(grid): copy column names with quoting options

This commit is contained in:
Jamie authored and GitHub committed 2026-07-25 08:47:51 +08:00
1 parent d341581ff0
commit 7eda73fbd8
15 files changed
+336 -11

No files matched your search

@@ -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;