feat(mongodb): support copying MongoDB documents
This commit is contained in:
parent
b8c488b398
commit
0992c52448
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
Loading…
Reference in New Issue