feat(grid): import BYTEA/BLOB cell from file with size gate
This commit is contained in:
20 files changed
+326
-17
No files matched your search
@@ -63,9 +63,12 @@ describe("dataGridContextMenu", () => {
|
||||
labels: { cellDetails: "cell", columnDetails: "column", rowDetails: "row", setNull: "null", bulkEdit: "bulk", transpose: "transpose" },
|
||||
icons: { cellDetails: icon, columnDetails: icon, rowDetails: icon, setNull: icon, bulkEdit: icon, transpose: icon },
|
||||
actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action },
|
||||
importItem: { label: "import" },
|
||||
downloadItem: { label: "download" },
|
||||
copySubmenu: { label: "copy" },
|
||||
clearSelectionItem: { label: "clear" },
|
||||
});
|
||||
expect(cellItems.slice(0, 4).map((item) => item.label)).toEqual(["cell", "import", "download", "column"]);
|
||||
expect(cellItems.find((item) => item.label === "bulk")?.disabled).toBe(true);
|
||||
|
||||
const rowItems = createDataGridRowContextMenuItems({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { binaryCellBytesToHexValue, canImportBinaryCellFile } from "@/lib/dataGrid/binaryCellDownload";
|
||||
|
||||
describe("binary cell file import", () => {
|
||||
it("encodes arbitrary and empty files as prefixed hex cell values", () => {
|
||||
expect(binaryCellBytesToHexValue(new Uint8Array([0x00, 0x01, 0xab, 0xff]))).toBe("0x0001abff");
|
||||
expect(binaryCellBytesToHexValue(new Uint8Array())).toBe("0x");
|
||||
});
|
||||
|
||||
it("offers file import only where prefixed hex values have binary save syntax", () => {
|
||||
expect(canImportBinaryCellFile("postgres", "bytea")).toBe(true);
|
||||
expect(canImportBinaryCellFile("mysql", "longblob")).toBe(true);
|
||||
expect(canImportBinaryCellFile("mysql", "varbinary(255)")).toBe(true);
|
||||
expect(canImportBinaryCellFile("postgres", "text")).toBe(false);
|
||||
expect(canImportBinaryCellFile("sqlserver", "varbinary(max)")).toBe(false);
|
||||
expect(canImportBinaryCellFile("sqlite", "blob")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk";
|
||||
|
||||
@@ -22,6 +23,86 @@ export interface BinaryCellDownloadResult {
|
||||
|
||||
export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"];
|
||||
|
||||
/**
|
||||
* 单个 BLOB/BYTEA 单元格从文件导入的最大字节数。
|
||||
*
|
||||
* 设立此上限的原因:导入路径会用 `@tauri-apps/plugin-fs` 的 `readFile(path)` 把整个文件
|
||||
* 一次性读进 `Uint8Array` 经 IPC 返回前端,再经 `binaryCellBytesToHexValue` 转成
|
||||
* `0x<hex>` 字符串(长度约为文件字节的 2 倍)作为脏值常驻内存,直到保存或放弃编辑。
|
||||
* 几百 MB 的 BLOB 即可造成 OOM/卡死,故在进入 readFile + hex 转换路径之前先按文件大小拦截。
|
||||
* 16 MB 对单个单元格导入已相当宽裕,分块流式 + 后端参数绑定属后续更大改造,不在此处处理。
|
||||
*/
|
||||
export const MAX_BINARY_CELL_IMPORT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 文件超过 {@link MAX_BINARY_CELL_IMPORT_BYTES} 时抛出的错误。
|
||||
* 调用方据 `code === "binary-import-too-large"` 选择专门的提示文案,
|
||||
* 其余失败仍走通用错误提示。
|
||||
*/
|
||||
export class BinaryCellImportTooLargeError extends Error {
|
||||
readonly code = "binary-import-too-large" as const;
|
||||
readonly bytes: number;
|
||||
readonly limit: number;
|
||||
constructor(bytes: number, limit: number) {
|
||||
super(`File is ${bytes} bytes, exceeds the ${limit}-byte import limit.`);
|
||||
this.name = "BinaryCellImportTooLargeError";
|
||||
this.bytes = bytes;
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
export function binaryCellBytesToHexValue(bytes: Uint8Array): string {
|
||||
let hex = "0x";
|
||||
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
||||
return hex;
|
||||
}
|
||||
|
||||
function openBinaryCellFileInBrowser(): Promise<Uint8Array | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.onchange = async () => {
|
||||
try {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// 尺寸闸门:File.size 在读取前即可用,避免大文件进入 arrayBuffer + hex 路径。
|
||||
if (file.size > MAX_BINARY_CELL_IMPORT_BYTES) {
|
||||
reject(new BinaryCellImportTooLargeError(file.size, MAX_BINARY_CELL_IMPORT_BYTES));
|
||||
return;
|
||||
}
|
||||
resolve(new Uint8Array(await file.arrayBuffer()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function openBinaryCellFile(): Promise<Uint8Array | undefined> {
|
||||
if (!isTauriRuntime()) return openBinaryCellFileInBrowser();
|
||||
|
||||
const [{ open }, { readFile, stat }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const selected = await open({ multiple: false });
|
||||
const path = Array.isArray(selected) ? selected[0] : selected;
|
||||
if (!path) return undefined;
|
||||
// 尺寸闸门:在 readFile 之前用 stat 取文件大小,避免大文件被全量读入内存。
|
||||
// stat 不可用(权限/平台差异等)时降级为不阻断,保留原 readFile 行为,避免功能完全不可用。
|
||||
try {
|
||||
const info = await stat(path);
|
||||
if (info.size > MAX_BINARY_CELL_IMPORT_BYTES) {
|
||||
throw new BinaryCellImportTooLargeError(info.size, MAX_BINARY_CELL_IMPORT_BYTES);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BinaryCellImportTooLargeError) throw error;
|
||||
console.warn("[binaryCellDownload] stat failed, skipping size gate:", error);
|
||||
}
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
export function retainBinaryCellDownloadMenuForHover(openCell: BinaryCellPosition | null, hoveredCell: BinaryCellPosition): BinaryCellPosition | null {
|
||||
return openCell?.rowIndex === hoveredCell.rowIndex && openCell.col === hoveredCell.col ? openCell : null;
|
||||
}
|
||||
@@ -30,6 +111,7 @@ const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/;
|
||||
const BARE_HEX_RE = /^[0-9a-fA-F\s]+$/;
|
||||
const HEX_ESCAPE_RE = /^(?:\\x[0-9a-fA-F]{2}|\s)+$/;
|
||||
const BINARY_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i;
|
||||
const MYSQL_FILE_IMPORT_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|binary|varbinary)(?:\b|\()/i;
|
||||
|
||||
function copyBytesForBlob(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(bytes);
|
||||
@@ -96,6 +178,13 @@ export function isBinaryCellColumnType(columnType?: string): boolean {
|
||||
return !!type && BINARY_TYPE_RE.test(type);
|
||||
}
|
||||
|
||||
export function canImportBinaryCellFile(databaseType?: DatabaseType, columnType?: string): boolean {
|
||||
const type = (columnType ?? "").trim();
|
||||
if (databaseType === "postgres") return /^bytea(?:\b|\()/i.test(type);
|
||||
if (databaseType === "mysql") return MYSQL_FILE_IMPORT_TYPE_RE.test(type);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canDownloadBinaryCellValue(value: unknown, columnType?: string): boolean {
|
||||
return !!parseBinaryCellBytes(value, columnType);
|
||||
}
|
||||
@@ -116,7 +205,7 @@ function binaryCellDisplayLabel(columnType?: string): string {
|
||||
return base;
|
||||
}
|
||||
|
||||
function formatBinaryCellByteSize(bytes: number): string {
|
||||
export function formatBinaryCellByteSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createDataGridCellContextMenuItems(options: {
|
||||
labels: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", string>;
|
||||
icons: Pick<DataGridContextMenuIcons, "cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose">;
|
||||
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
|
||||
importItem?: DataGridContextMenuItem | null;
|
||||
downloadItem?: DataGridContextMenuItem | null;
|
||||
foreignKeyItem?: DataGridContextMenuItem | null;
|
||||
copySubmenu: DataGridContextMenuItem;
|
||||
@@ -138,6 +139,7 @@ export function createDataGridCellContextMenuItems(options: {
|
||||
if (options.hasCell) {
|
||||
if (options.hasColumn) {
|
||||
items.push({ label: options.labels.cellDetails, action: options.actions.cellDetails, icon: options.icons.cellDetails });
|
||||
if (options.importItem) items.push(options.importItem);
|
||||
if (options.downloadItem) items.push(options.downloadItem);
|
||||
if (options.foreignKeyItem) items.push(options.foreignKeyItem);
|
||||
items.push({ label: options.labels.columnDetails, action: options.actions.columnDetails, icon: options.icons.columnDetails });
|
||||
|
||||
Reference in new issue
Block a user