feat(grid): support binary cell downloads
This commit is contained in:
parent
da6074caaa
commit
bbd07e564b
|
|
@ -96,6 +96,14 @@ import {
|
|||
} from "@/lib/dataGridTranspose";
|
||||
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
|
||||
import { displayCellValue, type CellValue } from "@/lib/cellValue";
|
||||
import {
|
||||
BINARY_CELL_DOWNLOAD_MODES,
|
||||
binaryCellDownloadFileName,
|
||||
binaryCellDownloadPayload,
|
||||
canDownloadBinaryCellValue,
|
||||
downloadBinaryCellPayload,
|
||||
type BinaryCellDownloadMode,
|
||||
} from "@/lib/binaryCellDownload";
|
||||
import {
|
||||
canFormatCellDetailJson,
|
||||
cellDetailEditorText,
|
||||
|
|
@ -2198,6 +2206,11 @@ const contextCellValue = computed<CellValue | null>(() => {
|
|||
if (!contextCell.value || contextCell.value.col < 0) return null;
|
||||
return contextRowItem.value?.data[contextCell.value.col] ?? null;
|
||||
});
|
||||
const contextCellDetail = computed(() => {
|
||||
const cell = contextCell.value;
|
||||
if (!cell || cell.col < 0) return null;
|
||||
return cellDetailFor(cell.rowIndex, cell.col);
|
||||
});
|
||||
function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetail | null {
|
||||
const item = displayItems.value[rowIndex];
|
||||
if (!item) return null;
|
||||
|
|
@ -3344,6 +3357,45 @@ function copyDetailColumnName() {
|
|||
copyText(activeCellDetail.value.column);
|
||||
}
|
||||
|
||||
function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolean {
|
||||
return !!detail && canDownloadBinaryCellValue(detail.value, detail.type);
|
||||
}
|
||||
|
||||
async function downloadDetailBinaryValue(detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) {
|
||||
if (!detail || !canDownloadDetailBinaryValue(detail)) return;
|
||||
try {
|
||||
const payload = binaryCellDownloadPayload(detail.value, mode);
|
||||
const fileName = binaryCellDownloadFileName({
|
||||
column: detail.column,
|
||||
rowNumber: detail.rowNumber,
|
||||
mode,
|
||||
extension: payload.extension,
|
||||
});
|
||||
const result = await downloadBinaryCellPayload(payload, fileName);
|
||||
if (result.kind === "saved" && result.path) {
|
||||
toast(t("grid.downloadSaved", { path: result.path }));
|
||||
} else if (result.kind === "browser-download") {
|
||||
toast(t("grid.downloadStarted", { fileName: result.fileName ?? fileName }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuItem | null {
|
||||
if (!canDownloadDetailBinaryValue(detail)) return null;
|
||||
return {
|
||||
label: t("grid.downloadBinaryValue"),
|
||||
icon: Download,
|
||||
children: BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({
|
||||
label: t(`grid.binaryDownload.${mode}`),
|
||||
action: () => {
|
||||
void downloadDetailBinaryValue(detail, mode);
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function copyDetailSqlCondition() {
|
||||
if (!canCopyPreparedDetailSqlCondition()) return;
|
||||
copyText(detailSqlConditionCopy.value.text);
|
||||
|
|
@ -4331,6 +4383,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
if (contextCell.value) {
|
||||
if (contextColumn.value) {
|
||||
items.push({ label: t("grid.openCellDetailsDialog"), action: openContextCellDetailDialog, icon: Maximize2 });
|
||||
const downloadItem = binaryDownloadSubmenu(contextCellDetail.value);
|
||||
if (downloadItem) items.push(downloadItem);
|
||||
items.push({
|
||||
label: t("grid.openColumnDetailsDialog"),
|
||||
action: openContextColumnDetailDialog,
|
||||
|
|
@ -6049,7 +6103,46 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<div v-if="!isEditingDetail" class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="activeCellDetail.isEditable"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('grid.editValue')"
|
||||
@click="startDetailEdit"
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('grid.copyValue')"
|
||||
@click="copyDetailValue"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<DropdownMenu v-if="canDownloadDetailBinaryValue(activeCellDetail)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :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="downloadDetailBinaryValue(activeCellDetail, mode)"
|
||||
>
|
||||
{{ t(`grid.binaryDownload.${mode}`) }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeCellDetail.imagePreviewUrl && !isEditingDetail" class="space-y-1.5">
|
||||
<div class="text-muted-foreground">{{ t("grid.imagePreview") }}</div>
|
||||
<a
|
||||
|
|
@ -6129,15 +6222,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</div>
|
||||
|
||||
<div class="border-t p-2 grid grid-cols-1 gap-1">
|
||||
<Button
|
||||
v-if="activeCellDetail.isEditable && !isEditingDetail"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 justify-start text-xs"
|
||||
@click="startDetailEdit"
|
||||
>
|
||||
<Pencil class="w-3 h-3 mr-2" /> {{ t("grid.editValue") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeCellDetail.isEditable && activeCellDetail.value !== null"
|
||||
variant="ghost"
|
||||
|
|
@ -6147,9 +6231,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
>
|
||||
<X class="w-3 h-3 mr-2" /> {{ t("grid.setNull") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailValue">
|
||||
<Copy class="w-3 h-3 mr-2" /> {{ t("grid.copyValue") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 justify-start text-xs" @click="copyDetailColumnName">
|
||||
<Copy class="w-3 h-3 mr-2" /> {{ t("grid.copyColumnName") }}
|
||||
</Button>
|
||||
|
|
@ -6379,7 +6460,46 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="dialogCellDetail.isEditable"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('grid.editValue')"
|
||||
@click="openDialogCellInSidePanel"
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('grid.copyValue')"
|
||||
@click="copyDialogCellValue"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<DropdownMenu v-if="canDownloadDetailBinaryValue(dialogCellDetail)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :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="downloadDetailBinaryValue(dialogCellDetail, mode)"
|
||||
>
|
||||
{{ t(`grid.binaryDownload.${mode}`) }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="dialogCellDetail.imagePreviewUrl"
|
||||
:href="dialogCellDetail.imagePreviewUrl"
|
||||
|
|
@ -6432,22 +6552,10 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
|
||||
<DialogFooter class="shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyDialogCellValue">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyValue") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyDialogCellColumnName">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnName") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="dialogCellDetail.isEditable"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="openDialogCellInSidePanel"
|
||||
>
|
||||
<Pencil class="mr-1.5 h-3 w-3" /> {{ t("grid.editValue") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -551,6 +551,14 @@ export default {
|
|||
formattedValue: "Formatted Value",
|
||||
rawValue: "Raw Value",
|
||||
copyValue: "Copy Value",
|
||||
downloadBinaryValue: "Download Value",
|
||||
downloadSaved: "Saved to {path}",
|
||||
downloadStarted: "Download started: {fileName}",
|
||||
binaryDownload: {
|
||||
binary: "Raw binary",
|
||||
utf8: "Decoded text (UTF-8)",
|
||||
gbk: "Decoded text (GBK)",
|
||||
},
|
||||
copyRowTsv: "Copy Row (TSV)",
|
||||
copyColumnValues: "Copy Column (JSON)",
|
||||
copyColumnTsv: "Copy Column (TSV)",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,14 @@ export default {
|
|||
formattedValue: "Valor formateado",
|
||||
rawValue: "Valor sin procesar",
|
||||
copyValue: "Copiar valor",
|
||||
downloadBinaryValue: "Descargar valor",
|
||||
downloadSaved: "Guardado en {path}",
|
||||
downloadStarted: "Descarga iniciada: {fileName}",
|
||||
binaryDownload: {
|
||||
binary: "Binario sin procesar",
|
||||
utf8: "Texto decodificado (UTF-8)",
|
||||
gbk: "Texto decodificado (GBK)",
|
||||
},
|
||||
copyRowTsv: "Copiar fila (TSV)",
|
||||
copyColumnValues: "Copiar columna (JSON)",
|
||||
copyColumnTsv: "Copiar columna (TSV)",
|
||||
|
|
|
|||
|
|
@ -546,6 +546,14 @@ export default {
|
|||
formattedValue: "格式化值",
|
||||
rawValue: "原始值",
|
||||
copyValue: "复制值",
|
||||
downloadBinaryValue: "下载值",
|
||||
downloadSaved: "已保存到 {path}",
|
||||
downloadStarted: "已交给浏览器下载:{fileName}",
|
||||
binaryDownload: {
|
||||
binary: "原始二进制",
|
||||
utf8: "解码文本 (UTF-8)",
|
||||
gbk: "解码文本 (GBK)",
|
||||
},
|
||||
copyRowTsv: "复制行 (TSV)",
|
||||
copyColumnValues: "复制列 (JSON)",
|
||||
copyColumnTsv: "复制列 (TSV)",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import type { CellValue } from "@/lib/cellValue";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk";
|
||||
|
||||
export interface BinaryCellDownloadPayload {
|
||||
data: Uint8Array | string;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export interface BinaryCellDownloadResult {
|
||||
kind: "saved" | "browser-download" | "cancelled";
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"];
|
||||
|
||||
const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/;
|
||||
const BINARY_TYPE_RE =
|
||||
/^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i;
|
||||
|
||||
export function parseBinaryCellHexValue(value: CellValue): Uint8Array | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.trim().match(HEX_VALUE_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const hex = match[1].replace(/\s+/g, "");
|
||||
if (!hex || hex.length % 2 !== 0) return null;
|
||||
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const parsed = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
bytes[i] = parsed;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function isBinaryCellColumnType(columnType?: string): boolean {
|
||||
const type = (columnType ?? "").trim();
|
||||
return !!type && BINARY_TYPE_RE.test(type);
|
||||
}
|
||||
|
||||
export function canDownloadBinaryCellValue(value: CellValue, columnType?: string): boolean {
|
||||
if (!parseBinaryCellHexValue(value)) return false;
|
||||
return isBinaryCellColumnType(columnType) || typeof value === "string";
|
||||
}
|
||||
|
||||
export function binaryCellDownloadPayload(value: CellValue, mode: BinaryCellDownloadMode): BinaryCellDownloadPayload {
|
||||
const bytes = parseBinaryCellHexValue(value);
|
||||
if (!bytes) {
|
||||
throw new Error("Cell value is not a hexadecimal binary value.");
|
||||
}
|
||||
|
||||
if (mode === "binary") {
|
||||
return {
|
||||
data: bytes,
|
||||
mimeType: "application/octet-stream",
|
||||
extension: "bin",
|
||||
};
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder(mode === "gbk" ? "gbk" : "utf-8", { fatal: false });
|
||||
return {
|
||||
data: decoder.decode(bytes),
|
||||
mimeType: "text/plain;charset=utf-8",
|
||||
extension: "txt",
|
||||
};
|
||||
}
|
||||
|
||||
export function binaryCellDownloadFileName(options: {
|
||||
column: string;
|
||||
rowNumber: number;
|
||||
mode: BinaryCellDownloadMode;
|
||||
extension: string;
|
||||
}): string {
|
||||
const column = options.column
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, 48);
|
||||
const safeColumn = column || "cell";
|
||||
const suffix = options.mode === "binary" ? "" : `-${options.mode}`;
|
||||
return `${safeColumn}-row-${options.rowNumber}${suffix}.${options.extension}`;
|
||||
}
|
||||
|
||||
export async function downloadBinaryCellPayload(
|
||||
payload: BinaryCellDownloadPayload,
|
||||
fileName: string,
|
||||
): Promise<BinaryCellDownloadResult> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: payload.extension.toUpperCase(), extensions: [payload.extension] }],
|
||||
});
|
||||
if (!path) return { kind: "cancelled" };
|
||||
if (typeof payload.data === "string") {
|
||||
await fs.writeTextFile(path, payload.data);
|
||||
} else {
|
||||
await fs.writeFile(path, payload.data);
|
||||
}
|
||||
return { kind: "saved", path };
|
||||
}
|
||||
|
||||
const blob = new Blob([payload.data], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return { kind: "browser-download", fileName };
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ MCP 查询执行默认非常保守:
|
|||
| 规则 | 默认行为 |
|
||||
|---|---|
|
||||
| 空 SQL 或无法识别的 SQL | 阻止 |
|
||||
| 超过一条 SQL 语句 | 阻止 |
|
||||
| 多条 SQL 语句 | 允许;会逐条进行安全检查并依次执行 |
|
||||
| 非只读 SQL | 阻止,除非显式允许写入 |
|
||||
| `DROP`、`TRUNCATE`、`ALTER` | 阻止,除非显式允许危险 SQL |
|
||||
| 允许写入时没有 `WHERE` 的 `UPDATE` 或 `DELETE` | 阻止 |
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ MCP query execution is intentionally conservative:
|
|||
| Rule | Default |
|
||||
|---|---|
|
||||
| Empty or unrecognized SQL | Blocked |
|
||||
| More than one SQL statement | Blocked |
|
||||
| Multiple SQL statements | Allowed; each statement is checked and executed separately |
|
||||
| Non-read SQL | Blocked unless writes are explicitly allowed |
|
||||
| `DROP`, `TRUNCATE`, or `ALTER` | Blocked unless dangerous SQL is explicitly allowed |
|
||||
| `UPDATE` or `DELETE` without `WHERE` when writes are allowed | Blocked |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
binaryCellDownloadFileName,
|
||||
binaryCellDownloadPayload,
|
||||
canDownloadBinaryCellValue,
|
||||
isBinaryCellColumnType,
|
||||
parseBinaryCellHexValue,
|
||||
} from "../../apps/desktop/src/lib/binaryCellDownload.ts";
|
||||
|
||||
test("parseBinaryCellHexValue accepts 0x and \\x prefixed hex values", () => {
|
||||
assert.deepEqual(Array.from(parseBinaryCellHexValue("0X48656c6c6f") ?? []), [72, 101, 108, 108, 111]);
|
||||
assert.deepEqual(Array.from(parseBinaryCellHexValue("\\x00 ff") ?? []), [0, 255]);
|
||||
});
|
||||
|
||||
test("parseBinaryCellHexValue rejects non-hex and odd-length payloads", () => {
|
||||
assert.equal(parseBinaryCellHexValue("hello"), null);
|
||||
assert.equal(parseBinaryCellHexValue("0x123"), null);
|
||||
assert.equal(parseBinaryCellHexValue(null), null);
|
||||
});
|
||||
|
||||
test("binary cell download detects common blob column types", () => {
|
||||
assert.equal(isBinaryCellColumnType("BLOB"), true);
|
||||
assert.equal(isBinaryCellColumnType("RAW(2000)"), true);
|
||||
assert.equal(isBinaryCellColumnType("long raw"), true);
|
||||
assert.equal(isBinaryCellColumnType("varchar"), false);
|
||||
});
|
||||
|
||||
test("canDownloadBinaryCellValue allows displayed binary hex strings", () => {
|
||||
assert.equal(canDownloadBinaryCellValue("0x89504e47", "BLOB"), true);
|
||||
assert.equal(canDownloadBinaryCellValue("0x89504e47"), true);
|
||||
assert.equal(canDownloadBinaryCellValue("89504e47", "BLOB"), false);
|
||||
});
|
||||
|
||||
test("binaryCellDownloadPayload builds raw and decoded payloads", () => {
|
||||
const binary = binaryCellDownloadPayload("0x4869", "binary");
|
||||
assert.equal(binary.mimeType, "application/octet-stream");
|
||||
assert.equal(binary.extension, "bin");
|
||||
assert.deepEqual(Array.from(binary.data as Uint8Array), [72, 105]);
|
||||
|
||||
const text = binaryCellDownloadPayload("0x4869", "utf8");
|
||||
assert.equal(text.mimeType, "text/plain;charset=utf-8");
|
||||
assert.equal(text.extension, "txt");
|
||||
assert.equal(text.data, "Hi");
|
||||
});
|
||||
|
||||
test("binaryCellDownloadPayload decodes GBK text bytes", () => {
|
||||
const payload = binaryCellDownloadPayload("0xd6d0cec4", "gbk");
|
||||
assert.equal(payload.data, "中文");
|
||||
});
|
||||
|
||||
test("binaryCellDownloadFileName sanitizes column names", () => {
|
||||
assert.equal(
|
||||
binaryCellDownloadFileName({ column: "avatar/blob", rowNumber: 7, mode: "gbk", extension: "txt" }),
|
||||
"avatar-blob-row-7-gbk.txt",
|
||||
);
|
||||
});
|
||||
|
|
@ -92,7 +92,7 @@ See the [DBX CLI README](../cli/README.md) for command details.
|
|||
|
||||
## SQL Safety
|
||||
|
||||
`dbx_execute_query` is read-only by default. To allow write statements such as `INSERT` or `UPDATE`, set:
|
||||
`dbx_execute_query` accepts multiple SQL statements and executes them one at a time after checking each statement. It is read-only by default. To allow write statements such as `INSERT` or `UPDATE`, set:
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_WRITES=1
|
||||
|
|
@ -213,7 +213,7 @@ dbx query local "select 1" --json
|
|||
|
||||
### SQL 安全
|
||||
|
||||
`dbx_execute_query` 默认只读。若要允许 `INSERT`、`UPDATE` 等写操作,设置:
|
||||
`dbx_execute_query` 支持多条 SQL 语句,会逐条完成安全检查并依次执行。默认只读。若要允许 `INSERT`、`UPDATE` 等写操作,设置:
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_WRITES=1
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import {
|
|||
parseMongoAggregateCommand,
|
||||
postBridge,
|
||||
sqlSafetyFromEnv,
|
||||
splitSqlStatements,
|
||||
type Backend,
|
||||
type ConnectionConfig,
|
||||
type QueryResult,
|
||||
} from "@dbx-app/node-core";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
@ -36,6 +38,13 @@ function withDatabase(config: ConnectionConfig, database?: string): ConnectionCo
|
|||
return database === undefined ? config : { ...config, database };
|
||||
}
|
||||
|
||||
function formatQueryToolResult(result: QueryResult, title?: string) {
|
||||
const prefix = title ? `${title}\n` : "";
|
||||
if (result.columns.length === 0) return text(`${prefix}Query executed. ${result.row_count} row(s) affected.`);
|
||||
const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c])));
|
||||
return text(`${prefix}${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`);
|
||||
}
|
||||
|
||||
export const DBX_CONNECTION_TYPE_DESCRIPTION =
|
||||
"Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, neo4j, cassandra, bigquery, kylin, sundb, tdengine, xugu, jdbc, access";
|
||||
|
||||
|
|
@ -108,16 +117,19 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
|||
const config = await backend.findConnection(connection_name);
|
||||
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
if (config.db_type !== "mongodb") {
|
||||
const safety = evaluateSqlSafety(sql, sqlSafetyFromEnv());
|
||||
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true });
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
|
||||
}
|
||||
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
|
||||
// (node-core executeQuery) applies command-aware read/write gating.
|
||||
try {
|
||||
const result = await backend.executeQuery(withDatabase(config, database), sql);
|
||||
if (result.columns.length === 0) return text(`Query executed. ${result.row_count} row(s) affected.`);
|
||||
const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c])));
|
||||
return text(`${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`);
|
||||
const statements = config.db_type === "mongodb" ? [sql] : splitSqlStatements(sql);
|
||||
const results = [];
|
||||
for (const statement of statements) {
|
||||
results.push(await backend.executeQuery(withDatabase(config, database), statement));
|
||||
}
|
||||
if (results.length === 1) return formatQueryToolResult(results[0]);
|
||||
return text(results.map((result, index) => formatQueryToolResult(result, `Statement ${index + 1}`).content[0].text).join("\n\n"));
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return toolError("QUERY_ERROR", msg);
|
||||
|
|
@ -235,7 +247,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
|||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "Query blocked.");
|
||||
}
|
||||
} else {
|
||||
const safety = evaluateSqlSafety(sql, safetyOptions);
|
||||
const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true });
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
|
||||
}
|
||||
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
|
||||
|
|
|
|||
|
|
@ -74,6 +74,48 @@ test("execute query scopes the connection to the requested database", async () =
|
|||
assert.equal(usedDatabase, "stores_demo");
|
||||
});
|
||||
|
||||
test("execute query runs safe multi-statement SQL one statement at a time", async () => {
|
||||
const executed: string[] = [];
|
||||
const scopedBackend: Backend = {
|
||||
...backend,
|
||||
executeQuery: async (_config, sql) => {
|
||||
executed.push(sql);
|
||||
return { columns: ["value"], rows: [{ value: executed.length }], row_count: 1 };
|
||||
},
|
||||
};
|
||||
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
||||
|
||||
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
||||
connection_name: "local",
|
||||
sql: "select 1; select 2;",
|
||||
});
|
||||
|
||||
assert.deepEqual(executed, ["select 1", "select 2"]);
|
||||
assert.match(result.content[0].text, /Statement 1/);
|
||||
assert.match(result.content[0].text, /Statement 2/);
|
||||
});
|
||||
|
||||
test("execute query reports the blocked statement number for unsafe multi-statement SQL", async () => {
|
||||
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
|
||||
process.env.DBX_MCP_ALLOW_WRITES = "1";
|
||||
const server = createDbxMcpServer(backend, { isWebMode: true });
|
||||
|
||||
try {
|
||||
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
||||
connection_name: "local",
|
||||
sql: "select 1; delete from users;",
|
||||
});
|
||||
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /SQL_BLOCKED:/);
|
||||
assert.match(result.content[0].text, /Statement 2/);
|
||||
assert.match(result.content[0].text, /WHERE/);
|
||||
} finally {
|
||||
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
|
||||
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
|
||||
}
|
||||
});
|
||||
|
||||
test("mongodb list tables returns collections from the selected database", async () => {
|
||||
let usedDatabase = "";
|
||||
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export interface SqlSafetyOptions {
|
||||
allowWrites?: boolean;
|
||||
allowDangerous?: boolean;
|
||||
allowMultipleStatements?: boolean;
|
||||
}
|
||||
|
||||
export interface SqlSafetyDecision {
|
||||
|
|
@ -14,9 +15,26 @@ const DANGEROUS_KEYWORDS = new Set(["drop", "truncate", "alter"]);
|
|||
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
||||
const statements = splitSqlStatements(sql);
|
||||
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
|
||||
if (statements.length > 1) return { allowed: false, reason: "Only one SQL statement is allowed per MCP query." };
|
||||
if (statements.length > 1 && !options.allowMultipleStatements) {
|
||||
return { allowed: false, reason: "Only one SQL statement is allowed per query." };
|
||||
}
|
||||
|
||||
const normalized = stripSqlCommentsAndStrings(statements[0]).trim();
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
const decision = evaluateSingleSqlStatementSafety(statements[i], options);
|
||||
if (!decision.allowed && statements.length > 1) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Statement ${i + 1}: ${decision.reason ?? "SQL blocked."}`,
|
||||
};
|
||||
}
|
||||
if (!decision.allowed) return decision;
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
||||
const normalized = stripSqlCommentsAndStrings(sql).trim();
|
||||
const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase();
|
||||
if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." };
|
||||
|
||||
|
|
@ -52,7 +70,7 @@ export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafet
|
|||
};
|
||||
}
|
||||
|
||||
function splitSqlStatements(sql: string): string[] {
|
||||
export function splitSqlStatements(sql: string): string[] {
|
||||
const statements: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | "`" | null = null;
|
||||
|
|
|
|||
|
|
@ -29,3 +29,26 @@ test("blocks update without where when writes are enabled", () => {
|
|||
assert.match(decision.reason ?? "", /WHERE/i);
|
||||
});
|
||||
|
||||
test("blocks multiple SQL statements unless explicitly allowed", () => {
|
||||
const decision = evaluateSqlSafety("select 1; select 2");
|
||||
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.match(decision.reason ?? "", /Only one SQL statement/);
|
||||
});
|
||||
|
||||
test("allows multiple read-only SQL statements when enabled", () => {
|
||||
const decision = evaluateSqlSafety("select 1; show tables", { allowMultipleStatements: true });
|
||||
|
||||
assert.equal(decision.allowed, true);
|
||||
});
|
||||
|
||||
test("checks every statement in a multi-statement SQL string", () => {
|
||||
const decision = evaluateSqlSafety("select 1; delete from users", {
|
||||
allowMultipleStatements: true,
|
||||
allowWrites: true,
|
||||
});
|
||||
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.match(decision.reason ?? "", /Statement 2/i);
|
||||
assert.match(decision.reason ?? "", /WHERE/i);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue