feat(grid): import BYTEA/BLOB cell from file with size gate

This commit is contained in:
zipg authored and GitHub committed 2026-08-09 20:13:18 +08:00
1 parent 31587bcbfe
commit 35362c8bf4
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 });