feat(mongodb): support copying MongoDB documents
This commit is contained in:
19 files changed
+580
-43
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user