feat(mongodb): support copying MongoDB documents

This commit is contained in:
二丫讲梵 2026-07-24 15:50:49 +08:00 committed by GitHub
parent b8c488b398
commit 0992c52448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 580 additions and 43 deletions

View File

@ -84,6 +84,7 @@ type ViewMode = "document" | "table";
const documents = ref<JsonRecord[]>([]);
const copyDocuments = ref<JsonRecord[]>([]);
const mongoCopyDocumentsAvailable = ref(false);
const lastGridColumns = ref<string[]>([]);
const total = ref<number | undefined>(undefined);
const totalIsExact = ref(true);
@ -112,6 +113,7 @@ const sortInput = ref("");
const filterInputRef = ref<HTMLTextAreaElement>();
const sortInputRef = ref<HTMLTextAreaElement>();
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
const mongoUpdateTarget = computed(() => (props.databaseType === "mongodb" && mongoCopyDocumentsAvailable.value ? { collection: props.collection, idColumn: "_id" as const } : undefined));
const documentViewerRef = ref<HTMLElement>();
const documentSearchInputRef = ref<HTMLInputElement>();
const documentSearchOpen = ref(false);
@ -822,9 +824,11 @@ async function load() {
}
})
: result.documents.map(asRecord);
const nextCopyDocuments = result.extended_documents?.length === nextDocuments.length ? result.extended_documents.map(asRecord) : nextDocuments;
const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length;
const nextCopyDocuments = hasTypePreservingCopyDocuments ? result.extended_documents!.map(asRecord) : nextDocuments;
documents.value = nextDocuments;
copyDocuments.value = nextCopyDocuments;
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
if (nextDocuments.length > 0) {
const keySet = new Set<string>();
keySet.add("_id");
@ -1569,6 +1573,7 @@ defineExpose({ focusSearch });
:result="gridResult"
context="results"
:database-type="props.databaseType"
:mongo-update-target="mongoUpdateTarget"
editable
:custom-save-handler="customSaveHandler"
:loading="loading"

View File

@ -177,7 +177,7 @@ import {
} from "@/lib/dataGrid/dataGridContextMenu";
import { useToast } from "@/composables/useToast";
import { useDataGridExport } from "@/composables/useDataGridExport";
import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport";
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
@ -304,6 +304,7 @@ interface DataGridProps {
allExportResults?: Array<{ sheetName: string; result: QueryResult; sql?: string }>;
exportFileBaseName?: string;
customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler;
mongoUpdateTarget?: MongoCopyUpdateTarget;
queryEditabilityReason?: QueryEditabilityReason;
allowInsertRows?: boolean;
allowDeleteRows?: boolean;
@ -5103,6 +5104,7 @@ const {
exportSql: computed(() => props.exportSql),
tableMeta: computed(() => (props.tableMeta ? { ...props.tableMeta } : undefined)),
copyInsertTargetLabel: computed(() => props.tableMeta?.tableName ?? props.customSaveHandler?.targetLabel),
mongoUpdateTarget: computed(() => props.mongoUpdateTarget),
databaseType: computed(() => props.databaseType),
connectionId: computed(() => props.connectionId),
database: computed(() => props.executionDatabase ?? props.database),

View File

@ -77,7 +77,7 @@ import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatab
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
import * as api from "@/lib/backend/api";
import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
@ -440,16 +440,22 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
// Replace the raw array only after every backend update succeeds, keeping
// the grid and JSON preview atomic when a multi-row save partially fails.
const replacements = new Map<unknown, unknown>();
if (tab.resultLocalSortOriginalMongoDocuments) {
tab.resultLocalSortOriginalMongoDocuments = applyMongoGridChangesToDocumentBaseline(tab.resultLocalSortOriginalMongoDocuments, documents, dirtyRows, columns);
}
tab.result!.mongo_documents = documents.map((document, rowIdx) => {
const changes = dirtyRows.get(rowIdx);
if (!changes) return document;
const updated = applyMongoGridChangesToDocument(document, changes, columns);
replacements.set(document, updated);
return updated;
return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document;
});
if (tab.resultLocalSortOriginalMongoDocuments) {
tab.resultLocalSortOriginalMongoDocuments = tab.resultLocalSortOriginalMongoDocuments.map((document) => replacements.get(document) ?? document);
const copyDocuments = tab.result!.mongo_copy_documents;
if (copyDocuments) {
if (tab.resultLocalSortOriginalMongoCopyDocuments) {
tab.resultLocalSortOriginalMongoCopyDocuments = applyMongoGridChangesToDocumentBaseline(tab.resultLocalSortOriginalMongoCopyDocuments, copyDocuments, dirtyRows, columns);
}
tab.result!.mongo_copy_documents = copyDocuments.map((document, rowIdx) => {
const changes = dirtyRows.get(rowIdx);
return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document;
});
}
};
@ -1168,6 +1174,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
:editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler"
:source-columns="activeTab.querySourceColumns"
:custom-save-handler="mongoQueryResultSaveHandler"
:mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined"
:query-editability-reason="activeTab.queryEditabilityReason"
:allow-insert-rows="activeTab.queryAnalysis?.allowInsert !== false && activeTab.queryAnalysis?.allowInsertDelete !== false"
:allow-delete-rows="activeTab.queryAnalysis?.allowInsertDelete !== false"

View File

@ -56,13 +56,24 @@ function row(data: unknown[]) {
};
}
function createMongoExportState(options: { columns: string[]; item: ReturnType<typeof row> & { sourceIndex: number }; mongoDocuments: unknown[]; selectedCellMatrix?: CellSelectionMatrix }) {
function createMongoExportState(options: {
columns: string[];
item: ReturnType<typeof row> & { sourceIndex: number };
items?: Array<ReturnType<typeof row> & { sourceIndex: number }>;
mongoDocuments: unknown[];
selectedCellMatrix?: CellSelectionMatrix;
selectedRowIds?: Set<number>;
mongoUpdateTarget?: false;
}) {
const items = options.items ?? [options.item];
const selectedRowIds = options.selectedRowIds ?? new Set<number>();
const state: UseDataGridExportOptions = {
columns: computed(() => options.columns),
displayItems: computed(() => [options.item]),
displayItems: computed(() => items),
sql: computed(() => undefined),
tableMeta: computed(() => undefined),
copyInsertTargetLabel: computed(() => "documents"),
mongoUpdateTarget: computed(() => (options.mongoUpdateTarget === false ? undefined : { collection: "documents", idColumn: "_id" })),
databaseType: computed(() => "mongodb"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
@ -78,9 +89,9 @@ function createMongoExportState(options: { columns: string[]; item: ReturnType<t
selectedCellMatrix: computed(() => options.selectedCellMatrix ?? null),
selectedRange: computed(() => null),
contextCell: ref({ rowId: options.item.id, rowIndex: 0, col: -1 }),
getRowItem: (rowId) => (rowId === options.item.id ? options.item : undefined),
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
getRowItem: (rowId) => items.find((item) => item.id === rowId),
selectedRowIds: ref(selectedRowIds),
hasRowSelection: computed(() => selectedRowIds.size > 0),
};
return useDataGridExport(state);
}
@ -337,6 +348,84 @@ describe("useDataGridExport prepared row statements", () => {
});`);
});
it("copies a Mongo row as updateOne while preserving BSON types and missing fields", async () => {
const item = {
...row(["507f1f77bcf86cd799439011", "123", 'NumberLong("9007199254740993")', null, null, '{"role":"maintainer"}', "12.34", "AQI="]),
sourceIndex: 0,
};
item.isDirtyCol = [false, false, false, false, false, true, false, false];
const state = createMongoExportState({
columns: ["_id", "numericText", "counter", "nullable", "missing", "profile", "decimal", "payload"],
item,
mongoDocuments: [
{
_id: { $oid: "507f1f77bcf86cd799439011" },
numericText: "123",
counter: { $numberLong: "9007199254740993" },
nullable: null,
profile: { role: "admin" },
decimal: { $numberDecimal: "12.34" },
payload: { $binary: { base64: "AQI=", subType: "00" } },
},
],
});
expect(state.canCopyRowAsUpdate.value).toBe(true);
await state.copyRowAsUpdate();
expect(buildDataGridCopyUpdateStatements).not.toHaveBeenCalled();
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
expect(copied).toContain('db.getCollection("documents")');
expect(copied).toContain(".updateOne(");
expect(copied).toContain('"_id": ObjectId("507f1f77bcf86cd799439011")');
expect(copied).toContain('"numericText": "123"');
expect(copied).toContain('"counter": NumberLong("9007199254740993")');
expect(copied).toContain('"nullable": null');
expect(copied).toContain('"profile":');
expect(copied).toContain('"role": "maintainer"');
expect(copied).toContain('"decimal": EJSON.deserialize(');
expect(copied).toContain('"$numberDecimal": "12.34"');
expect(copied).toContain('"payload": EJSON.deserialize(');
expect(copied).toContain('"$binary":');
expect(copied).toContain('"$unset":');
expect(copied).toContain('"missing": ""');
});
it("does not expose Mongo UPDATE copy without an explicit data-list target", async () => {
const item = { ...row(["507f1f77bcf86cd799439011", "Alice"]), sourceIndex: 0 };
const state = createMongoExportState({
columns: ["_id", "name"],
item,
mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }],
mongoUpdateTarget: false,
});
expect(state.canCopyRowAsUpdate.value).toBe(false);
await state.copyRowAsUpdate();
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("copies selected Mongo rows as separate updates using each sorted source document", async () => {
const first = { ...row(["second-id", "Second"]), id: 1, sourceIndex: 1 };
const second = { ...row(["first-id", "First"]), id: 2, sourceIndex: 0 };
const state = createMongoExportState({
columns: ["_id", "name"],
item: first,
items: [first, second],
mongoDocuments: [
{ _id: "first-id", name: "First" },
{ _id: "second-id", name: "Second" },
],
selectedRowIds: new Set([1, 2]),
});
await state.copyRowAsUpdate();
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
expect(copied.match(/\.updateOne\(/g)).toHaveLength(2);
expect(copied.indexOf('"_id": "second-id"')).toBeLessThan(copied.indexOf('"_id": "first-id"'));
});
it("preserves original Mongo types while limiting INSERT to the selected fields", async () => {
const item = { ...row(["123", "true", '{"kind":"literal"}']), sourceIndex: 0 };
const state = createMongoExportState({

View File

@ -13,7 +13,7 @@ import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats";
import { uuid } from "@/lib/common/utils";
import { useSettingsStore } from "@/stores/settingsStore";
import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue";
import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoCopyUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { QueryResultExportRequest } from "@/lib/backend/api";
@ -51,6 +51,11 @@ interface RowItem {
status: string;
}
export interface MongoCopyUpdateTarget {
collection: string;
idColumn: "_id";
}
export interface UseDataGridExportOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<RowItem[]>;
@ -58,6 +63,7 @@ export interface UseDataGridExportOptions {
exportSql?: ComputedRef<string | undefined>;
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
copyInsertTargetLabel?: ComputedRef<string | undefined>;
mongoUpdateTarget?: ComputedRef<MongoCopyUpdateTarget | undefined>;
databaseType: ComputedRef<DatabaseType | undefined>;
connectionId: ComputedRef<string | undefined>;
database: ComputedRef<string | undefined>;
@ -181,6 +187,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportSql: resultExportSql,
tableMeta,
copyInsertTargetLabel,
mongoUpdateTarget,
sourceColumns,
databaseType,
connectionId,
@ -328,6 +335,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
}
function buildMongoCopyUpdateStatements(rows: RowItem[], target: MongoCopyUpdateTarget): string[] {
const documents = options.mongoDocuments?.value;
if (!documents) return [];
const copyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? "");
const statements: string[] = [];
for (const item of rows) {
if (item.sourceIndex === undefined) continue;
const originalDocument = documents[item.sourceIndex];
if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) continue;
const source = originalDocument as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(source, target.idColumn)) continue;
const update = buildMongoCopyUpdateDocument(item.data as MongoInputValue[], copyColumns, item.isDirtyCol, originalDocument, target.idColumn);
if (!update) continue;
const statement = `db.getCollection(${JSON.stringify(target.collection)}).updateOne({${JSON.stringify(target.idColumn)}:${formatMongoShellLiteral(source[target.idColumn])}},${formatMongoShellLiteral(update)});`;
statements.push(formatMongoCopyStatement(statement) ?? statement);
}
return statements;
}
function insertCopyKey(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string {
return copyInsertKey(
{
@ -426,7 +455,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function buildCopyInsertStatement(data: CopyInsertData, excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): Promise<string | undefined> {
if (databaseType.value === "mongodb") {
return formatMongoCopyInsertStatement(
return formatMongoCopyStatement(
buildMongoCopyInsertStatement({
collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection",
columns: data.columns,
@ -633,8 +662,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function prepareRowAsUpdateStatement(): Promise<string | undefined> {
const currentTableMeta = tableMeta.value;
if (!currentTableMeta?.primaryKeys.length) {
const rows = updateEligibleRows();
if (!rows.length) {
setUpdateCopyCache({
key: "",
text: "",
@ -643,8 +672,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
return;
}
const rows = updateEligibleRows();
if (!rows.length) {
if (databaseType.value === "mongodb") {
const target = mongoUpdateTarget?.value;
if (!target) return;
await yieldToMainThread();
const text = buildMongoCopyUpdateStatements(rows, target).join("\n");
return text || undefined;
}
const currentTableMeta = tableMeta.value;
if (!currentTableMeta?.primaryKeys.length) {
setUpdateCopyCache({
key: "",
text: "",
@ -831,11 +869,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
const canCopyRowAsUpdate = computed(() => {
if (!tableMeta.value?.primaryKeys.length) return false;
const rows = updateEligibleRows();
if (!rows.length) return false;
if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false;
const saveColumns = effectiveColumns(sourceColumns.value, columns.value);
if (databaseType.value === "mongodb") {
const target = mongoUpdateTarget?.value;
if (!target || !options.mongoDocuments?.value || rows.some((item) => item.sourceIndex === undefined)) return false;
if (findColumnIndex(saveColumns, target.idColumn) === -1) return false;
return saveColumns.some((column) => column && normalizeColumnName(column) !== normalizeColumnName(target.idColumn));
}
if (!tableMeta.value?.primaryKeys.length) return false;
if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false;
const primaryKeys = tableMeta.value.primaryKeys;
if (primaryKeys.some((primaryKey) => findColumnIndex(saveColumns, primaryKey) === -1)) return false;
const primaryKeySet = new Set(primaryKeys.map(normalizeColumnName));
@ -1589,7 +1633,7 @@ function buildMongoCopyInsertStatement(options: { collection: string; columns: s
return `${collection}.insertMany(${formatMongoShellLiteral(documents)});`;
}
function formatMongoCopyInsertStatement(statement: string | undefined): string | undefined {
function formatMongoCopyStatement(statement: string | undefined): string | undefined {
if (!statement) return undefined;
try {
return formatMongoShellText(statement);

View File

@ -133,6 +133,7 @@ export function useSidebarDataOpenRuntime() {
tab.resultSortMode = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.resultSortedSql = undefined;
tab.resultPageSql = undefined;
tab.resultPageLimit = undefined;

View File

@ -7,6 +7,7 @@ const MONGO_INTEGER_PATTERN = /^-?\d+$/;
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const MIN_BSON_INT64 = -9223372036854775808n;
const MAX_BSON_INT64 = 9223372036854775807n;
const MONGO_EXTENDED_JSON_VALUE_KEYS = new Set(["$binary", "$code", "$date", "$dbPointer", "$maxKey", "$minKey", "$numberDecimal", "$numberDouble", "$numberInt", "$numberLong", "$oid", "$regularExpression", "$symbol", "$timestamp", "$undefined", "$uuid"]);
export function mongoShellDateToExtendedJson(value: unknown): unknown {
if (typeof value !== "string") return value;
@ -86,6 +87,39 @@ export function buildMongoUpdateDocument(changes: Map<number, MongoInputValue>,
return doc;
}
export function buildMongoCopyUpdateDocument(row: MongoInputValue[], columns: string[], dirtyColumns: boolean[], originalDocument?: unknown, idColumn = "_id"): Record<string, unknown> | null {
if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) return null;
const source = originalDocument as Record<string, unknown>;
const setFields: Record<string, unknown> = {};
const unsetFields: Record<string, unknown> = {};
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
const column = columns[columnIndex];
if (!column || column === idColumn) continue;
const value = row[columnIndex] ?? null;
if (dirtyColumns[columnIndex]) {
if (value === null) {
unsetFields[column] = "";
} else {
setFields[column] = parseMongoExistingFieldInputValue(value, source[column]);
}
continue;
}
if (Object.prototype.hasOwnProperty.call(source, column)) {
setFields[column] = source[column];
} else {
unsetFields[column] = "";
}
}
const update: Record<string, unknown> = {};
if (Object.keys(setFields).length > 0) update.$set = setFields;
if (Object.keys(unsetFields).length > 0) update.$unset = unsetFields;
return Object.keys(update).length > 0 ? update : null;
}
export function applyMongoGridChangesToDocument(document: unknown, changes: Map<number, MongoInputValue>, columns: string[]): unknown {
if (!document || typeof document !== "object" || Array.isArray(document)) return document;
@ -102,6 +136,26 @@ export function applyMongoGridChangesToDocument(document: unknown, changes: Map<
return updated;
}
function mongoDocumentIdentityKey(document: unknown): string | undefined {
if (!document || typeof document !== "object" || Array.isArray(document)) return undefined;
const object = document as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(object, "_id")) return undefined;
return JSON.stringify(object._id);
}
export function applyMongoGridChangesToDocumentBaseline(baselineDocuments: unknown[], currentDocuments: unknown[], dirtyRows: Map<number, Map<number, MongoInputValue>>, columns: string[]): unknown[] {
const changesByDocumentId = new Map<string, Map<number, MongoInputValue>>();
for (const [rowIndex, changes] of dirtyRows) {
const identityKey = mongoDocumentIdentityKey(currentDocuments[rowIndex]);
if (identityKey !== undefined) changesByDocumentId.set(identityKey, changes);
}
return baselineDocuments.map((document) => {
const identityKey = mongoDocumentIdentityKey(document);
const changes = identityKey === undefined ? undefined : changesByDocumentId.get(identityKey);
return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document;
});
}
export function buildMongoInsertDocument(row: MongoInputValue[], columns: string[]): Record<string, unknown> {
const doc: Record<string, unknown> = {};
for (let ci = 0; ci < columns.length; ci++) {
@ -167,6 +221,9 @@ export function formatMongoShellLiteral(value: unknown): string {
if (keys.length === 1 && typeof object.$numberLong === "string") {
return `NumberLong(${JSON.stringify(object.$numberLong)})`;
}
if ((keys.length === 1 && MONGO_EXTENDED_JSON_VALUE_KEYS.has(keys[0] ?? "")) || (keys.length === 2 && keys.includes("$code") && keys.includes("$scope"))) {
return `EJSON.deserialize(${JSON.stringify(object)})`;
}
return `{${keys.map((key) => `${JSON.stringify(key)}:${formatMongoShellLiteral(object[key])}`).join(",")}}`;
}
return JSON.stringify(String(value));

View File

@ -645,7 +645,7 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt
return { allowed: true };
}
export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number): QueryResult {
export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number, copyDocuments?: unknown[]): QueryResult {
const columns: string[] = [];
for (const doc of documents) {
@ -667,6 +667,7 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM
columns,
rows,
mongo_documents: documents,
...(copyDocuments?.length === documents.length ? { mongo_copy_documents: copyDocuments } : {}),
affected_rows: total,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
truncated: total > documents.length,

View File

@ -28,6 +28,7 @@ export interface TabResultSnapshot {
*/
resultLocalSortOriginalRows?: QueryResult["rows"];
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
resultRuns?: QueryTab["resultRuns"];
activeResultRunId?: string;
queryAnalysis?: QueryTab["queryAnalysis"];
@ -51,6 +52,7 @@ interface ColumnarQueryResult {
columnValues: CellValue[][];
rowCount: number;
mongo_documents?: unknown[];
mongo_copy_documents?: unknown[];
affected_rows: number;
execution_time_ms: number;
truncated?: boolean;
@ -324,6 +326,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
column_types: result.column_types ? [...result.column_types] : undefined,
rows: result.rows.map((row) => [...row]),
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
@ -347,6 +350,7 @@ function stripResultRunSessionIds(resultRuns: QueryTab["resultRuns"]): QueryTab[
results: stripResultSessionIds(run.results),
resultLocalSortOriginalRows: run.resultLocalSortOriginalRows?.map((row) => [...row]),
resultLocalSortOriginalMongoDocuments: run.resultLocalSortOriginalMongoDocuments ? clonePlain(run.resultLocalSortOriginalMongoDocuments) : undefined,
resultLocalSortOriginalMongoCopyDocuments: run.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(run.resultLocalSortOriginalMongoCopyDocuments) : undefined,
resultSessionId: undefined,
}));
}
@ -362,6 +366,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
columnValues,
rowCount: result.rows.length,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
@ -383,6 +388,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
column_types: result.column_types ? [...result.column_types] : undefined,
rows,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
affected_rows: result.affected_rows,
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
@ -702,6 +708,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
resultEditorFingerprint: tab.resultEditorFingerprint,
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows?.map((row) => [...row]),
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments ? clonePlain(tab.resultLocalSortOriginalMongoDocuments) : undefined,
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined,
resultRuns: stripResultRunSessionIds(tab.resultRuns),
activeResultRunId: tab.activeResultRunId,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,

View File

@ -123,6 +123,7 @@ function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): S
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
markRaw(result.rows);
if (result.mongo_documents) markRaw(result.mongo_documents);
if (result.mongo_copy_documents) markRaw(result.mongo_copy_documents);
return result;
}
@ -147,8 +148,9 @@ function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, m
const remainingRows = Math.max(0, maxRows - previous.rows.length);
const appendedRowCount = Math.min(remainingRows, segment.rows.length);
const appendParallelValues = <T>(existing: T[] | undefined, next: T[] | undefined): T[] | undefined => {
if (!existing && !next) return undefined;
return [...(existing ?? []), ...(next ?? []).slice(0, appendedRowCount)];
if (!existing || !next) return undefined;
if (existing.length !== previous.rows.length || next.length !== segment.rows.length) return undefined;
return [...existing, ...next.slice(0, appendedRowCount)];
};
// Keep prior row objects intact so source-index based dirty/new/deleted state
// remains valid, while bounding the in-memory result by the configured cap.
@ -169,6 +171,7 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns
if (run.results) markQueryResultsRowsRaw(run.results);
if (run.resultLocalSortOriginalRows) markRaw(run.resultLocalSortOriginalRows);
if (run.resultLocalSortOriginalMongoDocuments) markRaw(run.resultLocalSortOriginalMongoDocuments);
if (run.resultLocalSortOriginalMongoCopyDocuments) markRaw(run.resultLocalSortOriginalMongoCopyDocuments);
}
return resultRuns;
}
@ -596,6 +599,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultEditorFingerprint = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.resultSortMode = undefined;
tab.resultSessionId = undefined;
tab.resultAccessedAt = undefined;
@ -624,6 +628,7 @@ export const useQueryStore = defineStore("query", () => {
run.results = undefined;
run.resultLocalSortOriginalRows = undefined;
run.resultLocalSortOriginalMongoDocuments = undefined;
run.resultLocalSortOriginalMongoCopyDocuments = undefined;
run.resultSessionId = undefined;
run.resultEstimatedBytes = undefined;
run.queryAnalysis = undefined;
@ -650,6 +655,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortMode = run.resultSortMode;
tab.resultLocalSortOriginalRows = run.resultLocalSortOriginalRows;
tab.resultLocalSortOriginalMongoDocuments = run.resultLocalSortOriginalMongoDocuments;
tab.resultLocalSortOriginalMongoCopyDocuments = run.resultLocalSortOriginalMongoCopyDocuments;
tab.orderByInput = run.orderByInput;
tab.resultPageSql = run.resultPageSql;
tab.resultPageLimit = run.resultPageLimit;
@ -802,6 +808,7 @@ export const useQueryStore = defineStore("query", () => {
resultSortMode: tab.resultSortMode,
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
@ -855,6 +862,7 @@ export const useQueryStore = defineStore("query", () => {
resultSortMode: tab.resultSortMode,
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows,
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments,
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
@ -904,6 +912,7 @@ export const useQueryStore = defineStore("query", () => {
if (!tab.resultLocalSortOriginalRows) {
tab.resultLocalSortOriginalRows = tab.result.rows.slice();
tab.resultLocalSortOriginalMongoDocuments = tab.result.mongo_documents?.slice();
tab.resultLocalSortOriginalMongoCopyDocuments = tab.result.mongo_copy_documents?.slice();
}
const originalRows = tab.resultLocalSortOriginalRows;
@ -911,7 +920,9 @@ export const useQueryStore = defineStore("query", () => {
const rows = rowIndexes.map((index) => originalRows[index]!);
const originalMongoDocuments = tab.resultLocalSortOriginalMongoDocuments;
const mongo_documents = originalMongoDocuments ? rowIndexes.map((index) => originalMongoDocuments[index]) : undefined;
assignDisplayedResult(tab, { ...tab.result, rows, mongo_documents });
const originalMongoCopyDocuments = tab.resultLocalSortOriginalMongoCopyDocuments;
const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined;
assignDisplayedResult(tab, { ...tab.result, rows, mongo_documents, mongo_copy_documents });
tab.resultSortColumn = direction ? column : undefined;
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
@ -921,6 +932,7 @@ export const useQueryStore = defineStore("query", () => {
if (!direction) {
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
}
// 本地排序只是重排既有行/文档,字节规模不变,可复用估算值
@ -1824,6 +1836,7 @@ export const useQueryStore = defineStore("query", () => {
resultSortMode: undefined,
resultLocalSortOriginalRows: undefined,
resultLocalSortOriginalMongoDocuments: undefined,
resultLocalSortOriginalMongoCopyDocuments: undefined,
orderByInput: undefined,
resultPageSql: undefined,
resultPageLimit: undefined,
@ -2929,6 +2942,7 @@ export const useQueryStore = defineStore("query", () => {
tab.lastExecutedSql = sql;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
const updateActiveResultRun = !!tab.activeResultRunId && options?.preserveResultDuringExecution === true;
if (!updateActiveResultRun) {
tab.activeResultRunId = undefined;
@ -3095,9 +3109,9 @@ export const useQueryStore = defineStore("query", () => {
case "find": {
queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const result = await api.mongoFindDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.skip, mongoCommand.limit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, executionId);
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total)));
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents)));
allResults.push(queryResult);
mongoEditTarget = mongoCommands.length === 1 && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
queryExecutionLog("info", "mongo-find:done", {
traceId,
collection: mongoCommand.collection,
@ -3111,9 +3125,9 @@ export const useQueryStore = defineStore("query", () => {
case "findOne": {
queryExecutionLog("info", "mongo-find-one:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const result = await api.mongoFindOne(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId);
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total)));
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents)));
allResults.push(queryResult);
mongoEditTarget = mongoCommands.length === 1 && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
queryExecutionLog("info", "mongo-find-one:done", {
traceId,
collection: mongoCommand.collection,
@ -3158,7 +3172,7 @@ export const useQueryStore = defineStore("query", () => {
queryExecutionLog("info", "mongo-aggregate:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize);
const result = await api.mongoAggregateDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, mongoCommand.options, executionId);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total))));
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-aggregate:done", {
traceId,
@ -3237,7 +3251,7 @@ export const useQueryStore = defineStore("query", () => {
: mongoCommand.kind === "findOneAndReplace"
? await api.mongoFindOneAndReplace(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options)
: await api.mongoFindOneAndDelete(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total))));
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents))));
mongoEditTarget = undefined;
queryExecutionLog("info", "mongo-find-and-modify:done", {
traceId,
@ -4056,6 +4070,7 @@ export const useQueryStore = defineStore("query", () => {
tab.result = tab.results[index];
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
tab.resultSortDirection = undefined;
@ -4178,6 +4193,7 @@ export const useQueryStore = defineStore("query", () => {
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = snapshot.resultLocalSortOriginalMongoCopyDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoCopyDocuments) : undefined;
// 快照编解码会重建负载,落盘前的各 run 估算值不再对应恢复后的对象,
// 置空让 projectResultRun 按需重算
tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns;

View File

@ -565,6 +565,7 @@ export interface QueryResultRun {
resultSortMode?: "database" | "local";
resultLocalSortOriginalRows?: QueryResult["rows"];
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
orderByInput?: string;
resultPageSql?: string;
resultPageLimit?: number;
@ -796,6 +797,7 @@ export interface QueryTab {
resultSortMode?: "database" | "local";
resultLocalSortOriginalRows?: QueryResult["rows"];
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
orderByInput?: string;
resultPageSql?: string;
resultPageLimit?: number;

View File

@ -667,7 +667,7 @@ pub async fn find_documents(
while cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
documents.push(bson_to_json(&Bson::Document(doc.clone())));
extended_documents.push(Bson::Document(doc).into_relaxed_extjson());
extended_documents.push(Bson::Document(doc).into_canonical_extjson());
}
Ok(MongoDocumentResult {
@ -807,13 +807,16 @@ pub async fn find_documents_extended_json(
let mut cursor = find.await.map_err(|e| e.to_string())?;
let mut documents = Vec::new();
let mut extended_documents = Vec::new();
while cursor.advance().await.map_err(|e| e.to_string())? {
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
documents.push(bson_to_browser_json(&Bson::Document(doc)));
let (document, extended_document) = document_json_views(doc);
documents.push(document);
extended_documents.push(extended_document);
}
Ok(MongoDocumentResult {
extended_documents: Some(documents.clone()),
extended_documents: Some(extended_documents),
documents,
raw_documents: None,
total,
@ -1346,7 +1349,7 @@ fn single_document_result(document: Option<Document>) -> MongoDocumentResult {
Some(document) => MongoDocumentResult {
documents: vec![bson_to_json(&Bson::Document(document.clone()))],
raw_documents: None,
extended_documents: Some(vec![Bson::Document(document).into_relaxed_extjson()]),
extended_documents: Some(vec![Bson::Document(document).into_canonical_extjson()]),
total: 1,
total_is_exact: true,
},
@ -1584,6 +1587,14 @@ fn bson_to_browser_json(bson: &Bson) -> serde_json::Value {
}
}
fn document_json_views(document: Document) -> (serde_json::Value, serde_json::Value) {
let bson = Bson::Document(document);
let browser = bson_to_browser_json(&bson);
// Derive copy JSON from the original BSON so every BSON type keeps its canonical wrapper.
let extended = bson.into_canonical_extjson();
(browser, extended)
}
/// Convert a `serde_json::Value` (JSON object) to a BSON `Document`,
/// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`.
pub fn json_object_to_document(value: &serde_json::Value) -> Result<Document, String> {
@ -2271,10 +2282,40 @@ mod tests {
assert_eq!(result.documents[0]["lastUpdatedDate"], serde_json::json!("ISODate(\"2025-05-06T08:35:32Z\")"));
let extended = result.extended_documents.expect("extended documents");
assert_eq!(extended[0]["lastUpdatedDate"], serde_json::json!({ "$date": "2025-05-06T08:35:32Z" }));
assert_eq!(extended[0]["lastUpdatedDate"], serde_json::json!({ "$date": { "$numberLong": "1746520532000" } }));
assert_eq!(extended[0]["dateText"], serde_json::json!("ISODate(\"2025-05-06T08:35:32Z\")"));
}
#[test]
fn document_json_views_keep_browser_display_and_canonical_bson_types() {
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
let (browser, extended) = document_json_views(doc! {
"date": date,
"int32": Bson::Int32(42),
"int64": Bson::Int64(42),
"unsafeInt64": Bson::Int64(2_326_645_729_978_441_729),
});
assert_eq!(
browser,
serde_json::json!({
"date": "ISODate(\"2026-06-10T13:59:31.287Z\")",
"int32": 42,
"int64": 42,
"unsafeInt64": { "$numberLong": "2326645729978441729" },
})
);
assert_eq!(
extended,
serde_json::json!({
"date": { "$date": { "$numberLong": "1781099971287" } },
"int32": { "$numberInt": "42" },
"int64": { "$numberLong": "42" },
"unsafeInt64": { "$numberLong": "2326645729978441729" },
})
);
}
#[test]
fn bson_to_json_preserves_unsafe_int64_for_js() {
let value = bson_to_json(&Bson::Int64(2_326_645_729_978_441_729));

View File

@ -28,6 +28,7 @@ test("mongo document table passes copy context to the data grid", () => {
assert.match(source, /const customSaveHandler = computed<CustomSaveHandler>\(\(\) => \(\{[\s\S]*?targetLabel: props\.collection,[\s\S]*?\}\)\);/);
assert.match(source, /mongo_copy_documents: copyDocuments\.value/);
assert.match(source, /result\.extended_documents\?\.length === nextDocuments\.length/);
assert.match(source, /props\.databaseType === "mongodb" && mongoCopyDocumentsAvailable\.value/);
});
test("document edit mode toggles whole JSON editing for insert and save", () => {

View File

@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "vitest";
import {
applyMongoGridChangesToDocument,
applyMongoGridChangesToDocumentBaseline,
buildMongoCopyDocumentFromOriginal,
buildMongoCopyInsertDocument,
buildMongoInsertDocument,
@ -175,6 +176,20 @@ test("applies Mongo grid edits without converting existing JSON strings", () =>
});
});
test("applies sorted Mongo grid edits to a cloned BSON baseline by document id", () => {
const currentDocuments = [
{ _id: { $oid: "507f1f77bcf86cd799439012" }, name: "Linus", counter: { $numberLong: "2" } },
{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Ada", counter: { $numberLong: "1" } },
];
const baselineDocuments = [structuredClone(currentDocuments[1]), structuredClone(currentDocuments[0])];
const dirtyRows = new Map([[0, new Map<number, string | number | boolean | null>([[1, "Grace"]])]]);
assert.deepEqual(applyMongoGridChangesToDocumentBaseline(baselineDocuments, currentDocuments, dirtyRows, ["_id", "name", "counter"]), [
{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Ada", counter: { $numberLong: "1" } },
{ _id: { $oid: "507f1f77bcf86cd799439012" }, name: "Grace", counter: { $numberLong: "2" } },
]);
});
test("builds Mongo inserts with parsed date values", () => {
assert.deepEqual(buildMongoInsertDocument(["ignored", 'new Date("2026-06-10T13:59:31.287Z")'], ["_id", "createdAt"]), {
createdAt: { $date: "2026-06-10T13:59:31.287Z" },
@ -261,6 +276,19 @@ test("formats extended JSON int64 values as Mongo shell NumberLong literals", ()
assert.equal(formatMongoShellLiteral({ snowflake: { $numberLong: "9007199254740993" } }), '{"snowflake":NumberLong("9007199254740993")}');
});
test("formats other extended JSON values through EJSON.deserialize", () => {
assert.equal(
formatMongoShellLiteral({
decimal: { $numberDecimal: "12.34" },
payload: { $binary: { base64: "AQI=", subType: "00" } },
timestamp: { $timestamp: { t: 42, i: 7 } },
pattern: { $regularExpression: { pattern: "^dbx", options: "i" } },
canonicalDate: { $date: { $numberLong: "1721779200000" } },
}),
'{"decimal":EJSON.deserialize({"$numberDecimal":"12.34"}),"payload":EJSON.deserialize({"$binary":{"base64":"AQI=","subType":"00"}}),"timestamp":EJSON.deserialize({"$timestamp":{"t":42,"i":7}}),"pattern":EJSON.deserialize({"$regularExpression":{"pattern":"^dbx","options":"i"}}),"canonicalDate":EJSON.deserialize({"$date":{"$numberLong":"1721779200000"}})}',
);
});
test("keeps normal Mongo values readable and unsafe Int64 editable", () => {
assert.equal(mongoDocumentDisplayValue(42), 42);
assert.equal(mongoDocumentDisplayValue(3.5), 3.5);

View File

@ -344,6 +344,16 @@ test("parseMongoWriteCommand accepts unquoted insert and update commands", () =>
});
});
test("parseMongoWriteCommand unwraps EJSON.deserialize values", () => {
assert.deepEqual(parseMongoWriteCommand('db.products.updateOne({_id: ObjectId("507f1f77bcf86cd799439011")}, {$set: {price: EJSON.deserialize({"$numberDecimal":"12.34"}), payload: EJSON.deserialize({"$binary":{"base64":"AQI=","subType":"00"}})}})'), {
kind: "update",
collection: "products",
filter: '{"_id": {"$oid":"507f1f77bcf86cd799439011"}}',
update: '{"$set": {"price": {"$numberDecimal":"12.34"}, "payload": {"$binary":{"base64":"AQI=","subType":"00"}}}}',
many: false,
});
});
test("parseMongoWriteCommand accepts legacy insert commands", () => {
assert.deepEqual(
parseMongoWriteCommand(`db.getCollection("accounting_reconciliations").insert({
@ -936,6 +946,17 @@ test("mongoDocumentsToQueryResult turns mongo documents into grid rows", () => {
assert.equal(result.truncated, true);
});
test("mongoDocumentsToQueryResult keeps aligned extended documents for copying", () => {
const documents = [{ _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, createdAt: 'ISODate("2026-07-24T00:00:00Z")' }];
const copyDocuments = [{ _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }];
const result = mongoDocumentsToQueryResult(documents, 5, 1, copyDocuments);
assert.deepEqual(result.mongo_documents, documents);
assert.deepEqual(result.mongo_copy_documents, copyDocuments);
assert.equal(mongoDocumentsToQueryResult(documents, 5, 1, []).mongo_copy_documents, undefined);
});
test("mongoDocumentsToQueryResult displays ids without losing raw type metadata", () => {
const documents = [
{ _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, name: "object id" },

View File

@ -38,6 +38,10 @@ test("query result archives round-trip query tab metadata and result runs", asyn
{ _id: "1", profile: { role: "admin" } },
{ _id: "2", profile: { role: "maintainer" } },
],
mongo_copy_documents: [
{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } },
{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } },
],
affected_rows: 0,
execution_time_ms: 3,
session_id: "live-session",
@ -66,6 +70,7 @@ test("query result archives round-trip query tab metadata and result runs", asyn
},
resultLocalSortOriginalRows: [[1, "pending"]],
resultLocalSortOriginalMongoDocuments: [{ _id: "1", status: "pending" }],
resultLocalSortOriginalMongoCopyDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, status: "pending" }],
});
const snapshot = buildTabResultSnapshot(tab);
assert.ok(snapshot);
@ -80,7 +85,10 @@ test("query result archives round-trip query tab metadata and result runs", asyn
assert.equal(decoded?.tab.schema, "public");
assert.equal(decoded?.tab.sql, "select * from revenue");
assert.equal(decoded?.snapshot.activeResultRunId, "run-2");
assert.deepEqual(decoded?.snapshot.resultRuns?.map((run) => run.sequence), [1, 2]);
assert.deepEqual(
decoded?.snapshot.resultRuns?.map((run) => run.sequence),
[1, 2],
);
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.rows, [
[1, "Ada"],
[2, "Linus"],
@ -89,9 +97,14 @@ test("query result archives round-trip query tab metadata and result runs", asyn
{ _id: "1", profile: { role: "admin" } },
{ _id: "2", profile: { role: "maintainer" } },
]);
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.mongo_copy_documents, [
{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } },
{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } },
]);
assert.equal(decoded?.snapshot.resultRuns?.[0]?.result?.session_id, undefined);
assert.deepEqual(decoded?.snapshot.resultLocalSortOriginalRows, [[1, "pending"]]);
assert.deepEqual(decoded?.snapshot.resultLocalSortOriginalMongoDocuments, [{ _id: "1", status: "pending" }]);
assert.deepEqual(decoded?.snapshot.resultLocalSortOriginalMongoCopyDocuments, [{ _id: { $oid: "507f1f77bcf86cd799439011" }, status: "pending" }]);
});
test("query result archives reject invalid files", async () => {

View File

@ -786,6 +786,7 @@ test("sortTabResultLocally sorts current rows and restores original order", () =
{ id: 1, name: "Ada", nested: { level: 1 } },
{ id: 3, name: "Linus", nested: { level: 3 } },
],
mongo_copy_documents: [{ copyId: 2 }, { copyId: 1 }, { copyId: 3 }],
affected_rows: 0,
execution_time_ms: 1,
};
@ -801,12 +802,24 @@ test("sortTabResultLocally sorts current rows and restores original order", () =
tab.result?.mongo_documents?.map((document) => (document as { id: number }).id),
[1, 2, 3],
);
assert.deepEqual(
tab.result?.mongo_copy_documents?.map((document) => (document as { copyId: number }).copyId),
[1, 2, 3],
);
assert.deepEqual(
tab.resultLocalSortOriginalMongoCopyDocuments?.map((document) => (document as { copyId: number }).copyId),
[2, 1, 3],
);
assert.equal(tab.resultSortColumn, "name");
assert.equal(tab.resultSortColumnIndex, 1);
assert.equal(tab.resultSortDirection, "asc");
assert.equal(tab.resultSortMode, "local");
assert.equal(tab.resultSortedSql, undefined);
// Cache/archive decoding rebuilds row objects, so sorting must rely on the
// persisted BSON copy baseline rather than row reference identity.
tab.result!.rows = tab.result!.rows.map((resultRow) => [...resultRow]);
tab.resultLocalSortOriginalRows = tab.resultLocalSortOriginalRows?.map((resultRow) => [...resultRow]);
store.sortTabResultLocally(tabId, "name", 1, "desc");
assert.deepEqual(tab.result?.rows, [
@ -818,6 +831,10 @@ test("sortTabResultLocally sorts current rows and restores original order", () =
tab.result?.mongo_documents?.map((document) => (document as { id: number }).id),
[3, 2, 1],
);
assert.deepEqual(
tab.result?.mongo_copy_documents?.map((document) => (document as { copyId: number }).copyId),
[3, 2, 1],
);
store.sortTabResultLocally(tabId, "name", 1, null);
@ -830,8 +847,13 @@ test("sortTabResultLocally sorts current rows and restores original order", () =
tab.result?.mongo_documents?.map((document) => (document as { id: number }).id),
[2, 1, 3],
);
assert.deepEqual(
tab.result?.mongo_copy_documents?.map((document) => (document as { copyId: number }).copyId),
[2, 1, 3],
);
assert.equal(tab.resultSortColumn, undefined);
assert.equal(tab.resultSortMode, undefined);
assert.equal(tab.resultLocalSortOriginalMongoCopyDocuments, undefined);
});
test("selecting a result run restores its displayed result without changing SQL draft", async () => {
@ -2596,11 +2618,28 @@ test("append pagination preserves existing rows and respects the memory cap", as
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
const firstRow = [1] as (string | number | boolean | null)[];
tab.result = { columns: ["id"], rows: [firstRow], affected_rows: 0, execution_time_ms: 3 };
tab.result = {
columns: ["id"],
rows: [firstRow],
mongo_documents: [{ id: 1 }],
mongo_copy_documents: [{ id: { $numberLong: "1" } }],
affected_rows: 0,
execution_time_ms: 3,
};
globalThis.fetch = withConnectionHealthMock(async (input) => {
if (String(input) === "/api/query/execute-multi") {
return Response.json([{ columns: ["id"], rows: [[2], [3]], affected_rows: 0, execution_time_ms: 4, has_more: true }]);
return Response.json([
{
columns: ["id"],
rows: [[2], [3]],
mongo_documents: [{ id: 2 }, { id: 3 }],
mongo_copy_documents: [{ id: { $numberLong: "2" } }, { id: { $numberLong: "3" } }],
affected_rows: 0,
execution_time_ms: 4,
has_more: true,
},
]);
}
return new Response("unexpected request", { status: 500 });
});
@ -2614,6 +2653,8 @@ test("append pagination preserves existing rows and respects the memory cap", as
});
assert.deepEqual(tab.result?.rows, [[1], [2]]);
assert.deepEqual(tab.result?.mongo_documents, [{ id: 1 }, { id: 2 }]);
assert.deepEqual(tab.result?.mongo_copy_documents, [{ id: { $numberLong: "1" } }, { id: { $numberLong: "2" } }]);
assert.equal(toRaw(tab.result?.rows[0]), firstRow);
assert.equal(tab.result?.execution_time_ms, 7);
assert.equal(tab.result?.has_more, false);
@ -2625,6 +2666,57 @@ test("append pagination preserves existing rows and respects the memory cap", as
}
});
test("append pagination drops incomplete Mongo copy metadata instead of misaligning rows", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-append-old-cache"));
const tabId = store.createTab("conn-append-old-cache", "db", "users", "data", "public");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.result = {
columns: ["id"],
rows: [[1]],
mongo_documents: [{ id: 1 }],
affected_rows: 0,
execution_time_ms: 3,
};
globalThis.fetch = withConnectionHealthMock(async (input) => {
if (String(input) === "/api/query/execute-multi") {
return Response.json([
{
columns: ["id"],
rows: [[2]],
mongo_documents: [{ id: 2 }],
mongo_copy_documents: [{ id: { $numberLong: "2" } }],
affected_rows: 0,
execution_time_ms: 4,
},
]);
}
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 1 OFFSET 1;', {
pagination: { limit: 1, offset: 1 },
appendResult: { maxRows: 2 },
preserveResultDuringExecution: true,
});
assert.deepEqual(tab.result?.rows, [[1], [2]]);
assert.deepEqual(tab.result?.mongo_documents, [{ id: 1 }, { id: 2 }]);
assert.equal(tab.result?.mongo_copy_documents, undefined);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("failed append pagination preserves the visible result", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
@ -3238,6 +3330,47 @@ test("mongo multi-find results use database and collection source labels", async
}
});
test("mongo projected find results do not enable copy as UPDATE", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection({
...conn("mongo-projection-1"),
db_type: "mongodb",
port: 27017,
});
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
if (String(input) === "/api/document-store/find-documents") {
const body = JSON.parse(String(init?.body ?? "{}"));
assert.equal(body.projection, '{"_id":1,"profile.name":1}');
return new Response(
JSON.stringify({
documents: [{ _id: "user-1", profile: { name: "Ada" } }],
extended_documents: [{ _id: "user-1", profile: { name: "Ada" } }],
total: 1,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("unexpected request", { status: 500 });
});
try {
const tabId = store.createTab("mongo-projection-1", "accounting", "Query", "query", "");
await store.executeTabSql(tabId, 'db.users.find({}, { _id: 1, "profile.name": 1 })');
const tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.mongoEditTarget, undefined);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("replacing one paginated SQL result preserves the grouped refresh SQL", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());

View File

@ -22,6 +22,7 @@ test("result snapshots strip live session handles and clone result rows", () =>
columns: ["id"],
rows: [[1]],
mongo_documents: [{ _id: "1", profile: { role: "admin" } }],
mongo_copy_documents: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }],
affected_rows: 0,
execution_time_ms: 1,
session_id: "live-session",
@ -40,6 +41,7 @@ test("result snapshots strip live session handles and clone result rows", () =>
activeResultIndex: 0,
resultLocalSortOriginalRows: [[2]],
resultLocalSortOriginalMongoDocuments: [{ _id: "2", profile: { role: "maintainer" } }],
resultLocalSortOriginalMongoCopyDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }],
});
const snapshot = buildTabResultSnapshot(tab);
@ -50,11 +52,15 @@ test("result snapshots strip live session handles and clone result rows", () =>
assert.equal(snapshot?.results?.[0]?.session_id, undefined);
assert.deepEqual(snapshot?.result?.rows, [[1]]);
assert.deepEqual(snapshot?.result?.mongo_documents, [{ _id: "1", profile: { role: "admin" } }]);
assert.deepEqual(snapshot?.result?.mongo_copy_documents, [{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }]);
assert.deepEqual(snapshot?.resultLocalSortOriginalRows, [[2]]);
assert.deepEqual(snapshot?.resultLocalSortOriginalMongoDocuments, [{ _id: "2", profile: { role: "maintainer" } }]);
assert.deepEqual(snapshot?.resultLocalSortOriginalMongoCopyDocuments, [{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }]);
tab.result!.rows[0]![0] = 2;
(tab.result!.mongo_copy_documents![0] as { createdAt: { $date: string } }).createdAt.$date = "changed";
tab.resultLocalSortOriginalRows![0]![0] = 3;
assert.deepEqual(snapshot?.result?.rows, [[1]]);
assert.deepEqual(snapshot?.result?.mongo_copy_documents, [{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }]);
assert.deepEqual(snapshot?.resultLocalSortOriginalRows, [[2]]);
});
@ -70,6 +76,7 @@ test("result snapshots strip session handles from result runs", () => {
result: {
columns: ["id"],
rows: [[1]],
mongo_copy_documents: [{ _id: { $oid: "507f1f77bcf86cd799439011" } }],
affected_rows: 0,
execution_time_ms: 1,
session_id: "live-run-session",
@ -78,6 +85,7 @@ test("result snapshots strip session handles from result runs", () => {
},
resultLocalSortOriginalRows: [[2]],
resultLocalSortOriginalMongoDocuments: [{ _id: "2", role: "maintainer" }],
resultLocalSortOriginalMongoCopyDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439012" } }],
},
],
});
@ -88,8 +96,10 @@ test("result snapshots strip session handles from result runs", () => {
assert.equal(snapshot?.resultRuns?.[0]?.result?.sourceLabel, "users");
assert.equal(snapshot?.resultRuns?.[0]?.result?.sourceStatement, "select * from users");
assert.deepEqual(snapshot?.resultRuns?.[0]?.result?.rows, [[1]]);
assert.deepEqual(snapshot?.resultRuns?.[0]?.result?.mongo_copy_documents, [{ _id: { $oid: "507f1f77bcf86cd799439011" } }]);
assert.deepEqual(snapshot?.resultRuns?.[0]?.resultLocalSortOriginalRows, [[2]]);
assert.deepEqual(snapshot?.resultRuns?.[0]?.resultLocalSortOriginalMongoDocuments, [{ _id: "2", role: "maintainer" }]);
assert.deepEqual(snapshot?.resultRuns?.[0]?.resultLocalSortOriginalMongoCopyDocuments, [{ _id: { $oid: "507f1f77bcf86cd799439012" } }]);
});
test("result snapshots encode as binary columnar payloads and decode back to rows", () => {
@ -105,6 +115,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row
{ _id: "1", name: "Ada", tags: ["admin"] },
{ _id: "2", name: "Linus", tags: ["maintainer"] },
],
mongo_copy_documents: [
{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } },
{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } },
],
affected_rows: 0,
execution_time_ms: 3,
session_id: "live-session",
@ -120,6 +134,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row
{ _id: "2", name: "Linus", tags: ["maintainer"] },
{ _id: "1", name: "Ada", tags: ["admin"] },
],
resultLocalSortOriginalMongoCopyDocuments: [
{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } },
{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } },
],
}),
);
assert.ok(snapshot);
@ -137,6 +155,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row
{ _id: "1", name: "Ada", tags: ["admin"] },
{ _id: "2", name: "Linus", tags: ["maintainer"] },
]);
assert.deepEqual(decoded?.result?.mongo_copy_documents, [
{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } },
{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } },
]);
assert.deepEqual(decoded?.resultLocalSortOriginalRows, [
[2, "Linus", false],
[1, "Ada", true],
@ -145,6 +167,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row
{ _id: "2", name: "Linus", tags: ["maintainer"] },
{ _id: "1", name: "Ada", tags: ["admin"] },
]);
assert.deepEqual(decoded?.resultLocalSortOriginalMongoCopyDocuments, [
{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } },
{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } },
]);
assert.equal(decoded?.result?.session_id, undefined);
assert.equal(decoded?.result?.has_more, true);
assert.equal(decoded?.result?.sourceLabel, "public.users");

View File

@ -9,9 +9,10 @@ export function normalizeJsonArgument(value: string): string | null {
if (!trimmed) return "{}";
const withoutComments = stripMongoJsonComments(trimmed).trim();
if (!withoutComments) return "{}";
const withoutEjsonDeserialize = replaceMongoEjsonDeserialize(withoutComments);
// Rewrite mongo shell constructors that are not valid JSON into extended JSON
// (mongo_driver::json_value_to_bson): ObjectId / NumberLong / ISODate / new Date.
const withExtendedJson = replaceMongoShellConstructors(withoutComments);
const withExtendedJson = replaceMongoShellConstructors(withoutEjsonDeserialize);
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson));
try {
JSON.parse(preprocessed);
@ -286,6 +287,48 @@ function shouldQuoteObjectKey(source: string, index: number): boolean {
return source[after] === ":";
}
function replaceMongoEjsonDeserialize(source: string): string {
const callPattern = /^EJSON\s*\.\s*deserialize\s*\(/;
let result = "";
let index = 0;
while (index < source.length) {
const quote = source[index];
if (quote === '"' || quote === "'") {
const start = index++;
while (index < source.length) {
if (source[index] === "\\") index += 2;
else if (source[index] === quote) {
index++;
break;
} else index++;
}
result += source.slice(start, index);
continue;
}
const match = source.slice(index).match(callPattern);
if (!match) {
result += source[index++]!;
continue;
}
const openIndex = index + match[0].lastIndexOf("(");
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex < 0) {
result += source[index++]!;
continue;
}
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
if (args.length !== 1 || !args[0]?.trim()) {
result += source.slice(index, closeIndex + 1);
index = closeIndex + 1;
continue;
}
result += args[0].trim();
index = closeIndex + 1;
}
return result;
}
function replaceMongoShellConstructors(source: string): string {
const constructor = /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/;
let result = "";