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

This commit is contained in:
zipg 2026-08-09 20:13:18 +08:00 committed by GitHub
parent 31587bcbfe
commit 35362c8bf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 326 additions and 17 deletions

View File

@ -6,7 +6,8 @@ import {
ArrowDown,
ArrowUpDown,
ArrowUpRight,
Upload,
Download,
FileUp,
Trash2,
ChevronDown,
ChevronUp,
@ -122,12 +123,17 @@ import { getApplicablePreviewActions } from "@/lib/dataGrid/resultPreviewRegistr
import "@/lib/dataGrid/geometryMapPreview";
import {
BINARY_CELL_DOWNLOAD_MODES,
BinaryCellImportTooLargeError,
binaryCellBytesToHexValue,
binaryCellDisplayText,
binaryCellDownloadFileName,
binaryCellDownloadPayload,
canImportBinaryCellFile,
canDownloadBinaryCellValue,
downloadBinaryCellPayload,
formatBinaryCellByteSize,
isBinaryCellColumnType,
openBinaryCellFile,
parseBinaryCellBytes,
retainBinaryCellDownloadMenuForHover,
type BinaryCellDownloadMode,
@ -7063,6 +7069,39 @@ function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolea
return !!detail && canDownloadBinaryCellValue(detail.value, detail.type);
}
function canImportDetailBinaryValue(detail: DataGridCellDetail | null): boolean {
return !!detail?.isEditable && canImportBinaryCellFile(resolvedDatabaseType.value, detail.type);
}
async function importDetailBinaryValue(detail: DataGridCellDetail | null) {
if (!detail || !canImportDetailBinaryValue(detail)) return;
try {
const bytes = await openBinaryCellFile();
if (!bytes) return;
const value = binaryCellBytesToHexValue(bytes);
applyCellValue(detail.rowId, detail.colIndex, value);
if (activeCellDetail.value?.rowId === detail.rowId && activeCellDetail.value.colIndex === detail.colIndex) {
detailEditValue.value = value;
detailEditOriginalValue.value = value;
syncEditorFromDetailEdit();
detailCell.value = detailCell.value ? { ...detailCell.value } : null;
}
toast(t("grid.binaryImportApplied", { count: bytes.length }));
} catch (e: any) {
if (e instanceof BinaryCellImportTooLargeError) {
toast(
t("grid.binaryImportTooLarge", {
size: formatBinaryCellByteSize(e.bytes),
limit: formatBinaryCellByteSize(e.limit),
}),
5000,
);
return;
}
toast(t("grid.binaryImportFailed", { message: e?.message || String(e) }), 5000);
}
}
function canQuickDownloadCellValue(rowIndex: number, columnIndex: number): boolean {
return canDownloadDetailBinaryValue(cellDetailFor(rowIndex, columnIndex));
}
@ -7096,7 +7135,7 @@ function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuIt
if (!canDownloadDetailBinaryValue(detail)) return null;
return {
label: t("grid.downloadBinaryValue"),
icon: Upload,
icon: Download,
children: BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({
label: t(`grid.binaryDownload.${mode}`),
action: () => {
@ -7106,6 +7145,17 @@ function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuIt
};
}
function binaryImportItem(detail: DataGridCellDetail | null): ContextMenuItem | null {
if (!canImportDetailBinaryValue(detail)) return null;
return {
label: t("grid.importBinaryValue"),
icon: FileUp,
action: () => {
void importDetailBinaryValue(detail);
},
};
}
async function copyDetailSqlCondition() {
if (!canCopyPreparedDetailSqlCondition()) return;
copyText(detailSqlConditionCopy.value.text);
@ -8846,7 +8896,7 @@ function exportSubmenu(): ContextMenuItem {
{ label: t("grid.exportSelectedRowsTxt"), action: exportSelectedRowsTxt },
);
}
return { label: t("grid.export"), icon: Upload, children: items };
return { label: t("grid.export"), icon: Download, children: items };
}
const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@ -8939,6 +8989,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
},
icons: { cellDetails: Maximize2, columnDetails: TableProperties, rowDetails: ListTree, setNull: X, bulkEdit: Pencil, transpose: Rows3 },
actions: { cellDetails: openContextCellDetailDialog, columnDetails: openContextColumnDetailDialog, rowDetails: openContextRowDetailDialog, setNull: setSelectionNull, bulkEdit: openBulkEditDialog, transpose: openContextTranspose },
importItem: binaryImportItem(contextCellDetail.value),
downloadItem: binaryDownloadSubmenu(contextCellDetail.value),
foreignKeyItem: contextForeignKeyMenuItem(),
copySubmenu: copySubmenu(),
@ -9376,7 +9427,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</button>
</template>
</LightDropdownMenu>
@ -9890,7 +9941,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</button>
</template>
</LightDropdownMenu>
@ -10093,7 +10144,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</button>
</template>
</LightDropdownMenu>
@ -10408,6 +10459,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:type-color-class="typeColorClass"
:can-download-binary-value="canDownloadDetailBinaryValue"
:download-binary-value="downloadDetailBinaryValue"
:can-import-binary-value="canImportDetailBinaryValue"
:import-binary-value="importDetailBinaryValue"
:open-image-preview="openImagePreview"
:can-copy-sql-condition="canCopyPreparedDetailSqlCondition"
@start-edit="startDetailEdit"
@ -10582,6 +10635,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:copy-text="copyText"
:can-download-binary-value="canDownloadDetailBinaryValue"
:download-binary-value="downloadDetailBinaryValue"
:can-import-binary-value="canImportDetailBinaryValue"
:import-binary-value="importDetailBinaryValue"
@edit="openDialogCellInSidePanel"
/>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { Code2, Copy, Eye, Info, Pencil, Upload } from "@lucide/vue";
import { Code2, Copy, Download, Eye, FileUp, Info, Pencil } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -25,6 +25,8 @@ const props = defineProps<{
copyText: (text: string) => void;
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
}>();
const emit = defineEmits<{
@ -152,10 +154,13 @@ watch(
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.copyValue')" @click="copyCurrentValue">
<Copy class="h-3 w-3" />
</Button>
<Button v-if="canImportBinaryValue(detail)" variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.importBinaryValue')" @click="importBinaryValue(detail)">
<FileUp class="h-3 w-3" />
</Button>
<DropdownMenu v-if="canDownloadBinaryValue(detail)">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.downloadBinaryValue')">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { toRef } from "vue";
import { Code2, Copy, Eye, Pencil, Upload, X } from "@lucide/vue";
import { Code2, Copy, Download, Eye, FileUp, Pencil, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@ -30,6 +30,8 @@ const props = defineProps<{
typeColorClass: (type: string) => string;
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
openImagePreview: (src: string, title: string) => void;
canCopySqlCondition: () => boolean;
}>();
@ -122,9 +124,10 @@ defineExpose({ openSearch });
<Button v-if="!editing && detail.formattedJson" :variant="sideJsonView ? 'secondary' : 'ghost'" size="sm" class="h-5 gap-1 px-1.5 text-xs" :title="t('grid.formattedJson')" @click="emit('toggleFormatted')"><Code2 class="h-3 w-3" />{{ t("grid.formattedJson") }}</Button>
<Button v-if="!editing && detail.isEditable" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.editValue')" @click="emit('startEdit')"><Pencil class="h-3 w-3" /></Button>
<Button v-if="!editing" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.copyValue')" @click="emit('copyValue')"><Copy class="h-3 w-3" /></Button>
<Button v-if="canImportBinaryValue(detail)" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.importBinaryValue')" @click="importBinaryValue(detail)"><FileUp class="h-3 w-3" /></Button>
<DropdownMenu v-if="!editing && canDownloadBinaryValue(detail)"
><DropdownMenuTrigger as-child
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.downloadBinaryValue')"><Upload class="h-3 w-3" /></Button></DropdownMenuTrigger
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.downloadBinaryValue')"><Download class="h-3 w-3" /></Button></DropdownMenuTrigger
><DropdownMenuContent align="end" class="w-44"
><DropdownMenuItem v-for="mode in BINARY_CELL_DOWNLOAD_MODES" :key="mode" @click="downloadBinaryValue(detail, mode)">{{ t(`grid.binaryDownload.${mode}`) }}</DropdownMenuItem></DropdownMenuContent
></DropdownMenu

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Upload } from "@lucide/vue";
import { Download } from "@lucide/vue";
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
defineProps<{
@ -14,7 +14,7 @@ defineProps<{
model-value=""
:items="items"
:aria-label="label"
:trigger-icon="Upload"
:trigger-icon="Download"
:trigger-label="label"
trigger-class="inline-flex h-6 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 text-foreground/80 hover:bg-accent hover:text-accent-foreground"
trigger-icon-class="h-3.5 w-3.5"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Check, ChevronDown, Copy, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2, Upload } from "@lucide/vue";
import { Check, ChevronDown, Copy, Download, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -164,7 +164,7 @@ function actionLabelClass() {
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="exportData?.disabled || !exportData?.items.length">
<Upload class="data-grid-topbar-action-icon h-3 w-3" />
<Download class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ exportData?.label }}</span>
</Button>
</DropdownMenuTrigger>

View File

@ -25,8 +25,10 @@ vi.mock("@lucide/vue", async () => {
ChevronRight: icon,
ChevronsLeft: icon,
ChevronsRight: icon,
Download: icon,
Filter: icon,
Loader2: icon,
FileUp: icon,
Upload: icon,
Search: icon,
X: icon,
@ -873,7 +875,20 @@ describe("cell detail surfaces", () => {
const copyText = vi.fn();
const edit = vi.fn();
const updateOpen = vi.fn();
const mounted = mountComponent(DataGridCellDetailDialog, { open: true, detail: detail(), typeColorClass: () => "", openImagePreview: vi.fn(), copyText, canDownloadBinaryValue: () => false, downloadBinaryValue: vi.fn(), onEdit: edit, "onUpdate:open": updateOpen });
const importBinaryValue = vi.fn();
const mounted = mountComponent(DataGridCellDetailDialog, {
open: true,
detail: detail({ type: "BYTEA", isEditable: true }),
typeColorClass: () => "",
openImagePreview: vi.fn(),
copyText,
canDownloadBinaryValue: () => false,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => true,
importBinaryValue,
onEdit: edit,
"onUpdate:open": updateOpen,
});
await nextTick();
await nextTick();
@ -885,6 +900,11 @@ describe("cell detail surfaces", () => {
"click",
);
expect(edit).toHaveBeenCalledOnce();
dispatch(
findOne(mounted.root, (node) => node.props.title === "grid.importBinaryValue"),
"click",
);
expect(importBinaryValue).toHaveBeenCalledOnce();
await mounted.setProps({ detail: detail({ rawValue: '{"b":2}', formattedJson: '{\n "b": 2\n}' }) });
expect(mocks.editor.setValue).toHaveBeenCalledWith('{\n "b": 2\n}', "json");
@ -912,6 +932,8 @@ describe("cell detail surfaces", () => {
typeColorClass: () => "",
canDownloadBinaryValue: () => false,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
openImagePreview: vi.fn(),
canCopySqlCondition: () => true,
onStartEdit: startEdit,

View File

@ -1649,6 +1649,10 @@ export default {
rawValue: "Raw Value",
largeValuePreviewHint: "Previewing first {count} characters. Copy still uses the full value.",
copyValue: "Copy Value",
importBinaryValue: "Import from File",
binaryImportApplied: "Imported {count} bytes. Save changes to write them to the database.",
binaryImportFailed: "Failed to import file: {message}",
binaryImportTooLarge: "File is too large ({size}, limit {limit}). Importing large files into a single cell is not supported.",
downloadBinaryValue: "Download Value",
downloadSaved: "Saved to {path}",
downloadStarted: "Download started: {fileName}",

View File

@ -1675,6 +1675,10 @@ export default withEnglishFallback({
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
xlsxHeaderComment: "Encabezado usando comentarios",
jumpToPage: "Ir a la página",
importBinaryValue: "Importar desde archivo",
binaryImportApplied: "Se importaron {count} bytes, que se escribirán en la base de datos al guardar los cambios.",
binaryImportFailed: "Error al importar el archivo: {message}",
binaryImportTooLarge: "El archivo es demasiado grande ({size}, límite {limit}). No se admite importar archivos grandes en una sola celda.",
},
exportProgress: {
streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.",

View File

@ -1673,6 +1673,10 @@ export default withEnglishFallback({
xlsxHeaderOriginal: "Intestazione con nome campo",
xlsxHeaderComment: "Intestazione con commento",
jumpToPage: "Vai alla pagina",
importBinaryValue: "Importa da file",
binaryImportApplied: "Importati {count} byte, scritti nel database dopo il salvataggio delle modifiche.",
binaryImportFailed: "Importazione del file non riuscita: {message}",
binaryImportTooLarge: "Il file è troppo grande ({size}, limite {limit}). L'importazione di file di grandi dimensioni in una singola cella non è supportata.",
},
exportProgress: {
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",

View File

@ -1700,6 +1700,10 @@ export default withEnglishFallback({
searchModeHighlight: "ハイライト",
emptyStringValue: "空文字",
jumpToPage: "ページ番号に移動",
importBinaryValue: "ファイルからインポート",
binaryImportApplied: "{count} バイトをインポートしました。変更を保存するとデータベースに書き込まれます。",
binaryImportFailed: "ファイルのインポートに失敗しました:{message}",
binaryImportTooLarge: "ファイルが大きすぎます({size}、上限 {limit})。大きなファイルの単一セルへのインポートはサポートされていません。",
},
exportProgress: {
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",

View File

@ -1519,6 +1519,7 @@ export default withEnglishFallback({
largeValuePreviewHint: "처음 {count}자 미리보는 중. 복사에는 여전히 전체 값이 사용됩니다.",
copyValue: "값 복사",
downloadBinaryValue: "값 다운로드",
binaryImportTooLarge: "파일이 너무 큽니다({size}, 상한 {limit}). 큰 파일을 단일 셀에 가져오는 것은 지원되지 않습니다.",
downloadSaved: "{path}에 저장됨",
downloadStarted: "다운로드 시작됨: {fileName}",
binaryDownload: {

View File

@ -1675,6 +1675,10 @@ export default withEnglishFallback({
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
xlsxHeaderComment: "Cabeçalho usa comentário",
jumpToPage: "Ir para a página",
importBinaryValue: "Importar do arquivo",
binaryImportApplied: "{count} bytes importados, gravados no banco de dados após salvar as alterações.",
binaryImportFailed: "Falha ao importar o arquivo: {message}",
binaryImportTooLarge: "O arquivo é muito grande ({size}, limite {limit}). Não há suporte para importar arquivos grandes em uma única célula.",
},
exportProgress: {
streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.",

View File

@ -1649,6 +1649,10 @@ export default withEnglishFallback({
rawValue: "原始值",
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
copyValue: "复制值",
importBinaryValue: "从文件导入",
binaryImportApplied: "已导入 {count} 字节,保存更改后写入数据库。",
binaryImportFailed: "导入文件失败:{message}",
binaryImportTooLarge: "文件过大({size},上限 {limit})。不支持将大文件导入到单个单元格。",
downloadBinaryValue: "下载值",
downloadSaved: "已保存到 {path}",
downloadStarted: "已交给浏览器下载:{fileName}",

View File

@ -1524,6 +1524,10 @@ export default withEnglishFallback({
rawValue: "原始值",
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
copyValue: "複製值",
importBinaryValue: "從檔案匯入",
binaryImportApplied: "已匯入 {count} 位元組,儲存變更後寫入資料庫。",
binaryImportFailed: "匯入檔案失敗:{message}",
binaryImportTooLarge: "檔案過大({size},上限 {limit})。不支援將大檔案匯入到單一儲存格。",
downloadBinaryValue: "下載值",
downloadSaved: "已儲存到 {path}",
downloadStarted: "已交給瀏覽器下載:{fileName}",

View File

@ -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({

View File

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

View File

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

View File

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

View File

@ -1803,6 +1803,11 @@ pub fn format_grid_sql_literal(
return literal;
}
}
if is_postgres_binary_literal_column(database_type, column_info) {
if let Some(literal) = format_postgres_binary_literal_text(&text) {
return literal;
}
}
if is_oracle_raw_literal_column(database_type, column_info) {
if let Some(literal) = format_oracle_raw_literal_text(&text) {
return literal;
@ -2141,6 +2146,24 @@ fn format_mysql_binary_literal_text(text: &str) -> Option<String> {
}
}
fn is_postgres_binary_literal_column(
database_type: Option<DatabaseType>,
column_info: Option<&DataGridColumnInfo>,
) -> bool {
database_type == Some(DatabaseType::Postgres)
&& column_info.is_some_and(|column| column.data_type.trim().eq_ignore_ascii_case("bytea"))
}
fn format_postgres_binary_literal_text(text: &str) -> Option<String> {
let trimmed = text.trim();
let hex = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X"))?;
if hex.len() % 2 == 0 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
Some(format!("decode('{hex}', 'hex')"))
} else {
None
}
}
fn is_oracle_raw_literal_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
is_oracle_temporal_literal_database(database_type)
&& column_info.map(|column| is_oracle_raw_column_type(&column.data_type)).unwrap_or(false)
@ -5403,6 +5426,23 @@ mod tests {
assert_eq!(format_grid_sql_literal(&json!("it's"), Some(DatabaseType::Postgres), None), "'it''s'");
}
#[test]
fn postgres_bytea_literals_decode_prefixed_hex_values() {
let bytea = column("payload", "bytea", true, None);
let text = column("label", "text", true, None);
assert_eq!(
format_grid_sql_literal(&json!("0x00aBff"), Some(DatabaseType::Postgres), Some(&bytea)),
"decode('00aBff', 'hex')"
);
assert_eq!(
format_grid_sql_literal(&json!("0x"), Some(DatabaseType::Postgres), Some(&bytea)),
"decode('', 'hex')"
);
assert_eq!(format_grid_sql_literal(&json!("0xabc"), Some(DatabaseType::Postgres), Some(&bytea)), "'0xabc'");
assert_eq!(format_grid_sql_literal(&json!("0x00ab"), Some(DatabaseType::Postgres), Some(&text)), "'0x00ab'");
}
#[test]
fn sqlite_family_literals_do_not_double_escape_backslashes() {
let value = json!(r#"{"json_raw":"{\"foo\":1,\"bar\":\"sometext\"}"}"#);

View File

@ -2,7 +2,19 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { binaryCellDisplayText, binaryCellDownloadFileName, binaryCellDownloadPayload, canDownloadBinaryCellValue, isBinaryCellColumnType, parseBinaryCellBytes, parseBinaryCellHexValue, retainBinaryCellDownloadMenuForHover } from "../../apps/desktop/src/lib/dataGrid/binaryCellDownload.ts";
import {
BinaryCellImportTooLargeError,
binaryCellDisplayText,
binaryCellDownloadFileName,
binaryCellDownloadPayload,
canDownloadBinaryCellValue,
formatBinaryCellByteSize,
isBinaryCellColumnType,
MAX_BINARY_CELL_IMPORT_BYTES,
parseBinaryCellBytes,
parseBinaryCellHexValue,
retainBinaryCellDownloadMenuForHover,
} from "../../apps/desktop/src/lib/dataGrid/binaryCellDownload.ts";
test("parseBinaryCellHexValue accepts 0x and \\x prefixed hex values", () => {
assert.deepEqual(Array.from(parseBinaryCellHexValue("0X48656c6c6f") ?? []), [72, 101, 108, 108, 111]);
@ -77,3 +89,34 @@ test("binaryCellDownloadPayload decodes GBK text bytes", () => {
test("binaryCellDownloadFileName sanitizes column names", () => {
assert.equal(binaryCellDownloadFileName({ column: "avatar/blob", rowNumber: 7, mode: "gbk", extension: "txt" }), "avatar-blob-row-7-gbk.txt");
});
test("MAX_BINARY_CELL_IMPORT_BYTES is a sane upper bound for single-cell imports", () => {
// 16 MB: 单个 BLOB 单元格导入的保守上限,避免 readFile 全量读 + 2× hex 常驻导致 OOM。
assert.equal(MAX_BINARY_CELL_IMPORT_BYTES, 16 * 1024 * 1024);
});
test("BinaryCellImportTooLargeError carries code and byte/limit for toast formatting", () => {
const err = new BinaryCellImportTooLargeError(20 * 1024 * 1024, MAX_BINARY_CELL_IMPORT_BYTES);
assert.equal(err.code, "binary-import-too-large");
assert.equal(err.bytes, 20 * 1024 * 1024);
assert.equal(err.limit, MAX_BINARY_CELL_IMPORT_BYTES);
assert.ok(err instanceof Error);
});
test("formatBinaryCellByteSize formats human-readable sizes for the import toast", () => {
assert.equal(formatBinaryCellByteSize(512), "512 bytes");
assert.equal(formatBinaryCellByteSize(2048), "2.0 KB");
// bytes >= 10 MB 时按整数 MB 显示(对齐 binaryCellDisplayText 既有格式)。
assert.equal(formatBinaryCellByteSize(20 * 1024 * 1024), "20 MB");
});
test("DataGrid import handler surfaces a dedicated too-large toast instead of the generic failure", () => {
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
const handler = source.match(/async function importDetailBinaryValue\([^]*?\n\}/)?.[0] ?? "";
// 闸门错误必须走专门的文案,而非通用 binaryImportFailed。
assert.match(handler, /e instanceof BinaryCellImportTooLargeError/);
assert.match(handler, /grid\.binaryImportTooLarge/);
assert.match(handler, /formatBinaryCellByteSize\(e\.bytes\)/);
assert.match(handler, /formatBinaryCellByteSize\(e\.limit\)/);
});