fix: support mongodb copy insert statements
This commit is contained in:
parent
7b32c71b1b
commit
e2f6edba9c
|
|
@ -4764,6 +4764,7 @@ const {
|
|||
displayItems: visibleDisplayItems,
|
||||
sql: computed(() => props.sql),
|
||||
tableMeta: computed(() => (props.tableMeta ? { ...props.tableMeta } : undefined)),
|
||||
copyInsertTargetLabel: computed(() => props.tableMeta?.tableName ?? props.customSaveHandler?.targetLabel),
|
||||
databaseType: computed(() => props.databaseType),
|
||||
connectionId: computed(() => props.connectionId),
|
||||
database: computed(() => props.database),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { formatSqlInsert } from "@/lib/exportFormats";
|
|||
import { uuid } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { expandNestedJsonStringsForCopy } from "@/lib/jsonCopyValue";
|
||||
import { buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongoDocumentValues";
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import type { QueryResultExportRequest } from "@/lib/api";
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ export interface UseDataGridExportOptions {
|
|||
displayItems: ComputedRef<RowItem[]>;
|
||||
sql: ComputedRef<string | undefined>;
|
||||
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
|
||||
copyInsertTargetLabel?: ComputedRef<string | undefined>;
|
||||
databaseType: ComputedRef<DatabaseType | undefined>;
|
||||
connectionId: ComputedRef<string | undefined>;
|
||||
database: ComputedRef<string | undefined>;
|
||||
|
|
@ -100,6 +102,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
displayItems,
|
||||
sql,
|
||||
tableMeta,
|
||||
copyInsertTargetLabel,
|
||||
sourceColumns,
|
||||
databaseType,
|
||||
connectionId,
|
||||
|
|
@ -192,6 +195,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
databaseType: databaseType.value ?? null,
|
||||
schema: tableMeta.value?.schema ?? null,
|
||||
tableName: tableMeta.value?.tableName ?? null,
|
||||
copyInsertTargetLabel: copyInsertTargetLabel?.value ?? null,
|
||||
columns: columns.value,
|
||||
sourceColumns: sourceColumns.value ?? null,
|
||||
excludePrimaryKeys,
|
||||
|
|
@ -238,14 +242,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
});
|
||||
|
||||
try {
|
||||
const statement = await buildDataGridCopyInsertStatement({
|
||||
databaseType: databaseType.value,
|
||||
tableMeta: tableMeta.value,
|
||||
columns: columns.value,
|
||||
sourceColumns: sourceColumns.value,
|
||||
rows: rows.map((item) => item.data),
|
||||
excludePrimaryKeys,
|
||||
});
|
||||
const statement =
|
||||
databaseType.value === "mongodb"
|
||||
? buildMongoCopyInsertStatement({
|
||||
collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection",
|
||||
columns: columns.value,
|
||||
sourceColumns: sourceColumns.value,
|
||||
rows: rows.map((item) => item.data),
|
||||
excludePrimaryKeys,
|
||||
})
|
||||
: await buildDataGridCopyInsertStatement({
|
||||
databaseType: databaseType.value,
|
||||
tableMeta: tableMeta.value,
|
||||
columns: columns.value,
|
||||
sourceColumns: sourceColumns.value,
|
||||
rows: rows.map((item) => item.data),
|
||||
excludePrimaryKeys,
|
||||
});
|
||||
const latest = insertCopyCache(excludePrimaryKeys);
|
||||
if (latest.key !== key) return;
|
||||
setInsertCopyCache(excludePrimaryKeys, {
|
||||
|
|
@ -1093,6 +1106,17 @@ function replaceControlCharacters(value: string, replacement: string): string {
|
|||
.join("");
|
||||
}
|
||||
|
||||
function buildMongoCopyInsertStatement(options: { collection: string; columns: string[]; sourceColumns?: Array<string | undefined>; rows: CellValue[][]; excludePrimaryKeys?: boolean }): string | undefined {
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const columnIndexes = saveColumns.map((column, index) => ({ column, index })).filter((item): item is { column: string; index: number } => !!item.column);
|
||||
if (columnIndexes.length === 0 || options.rows.length === 0) return undefined;
|
||||
const documentColumns = columnIndexes.map((item) => item.column);
|
||||
const documents = options.rows.map((row) => buildMongoCopyInsertDocument(columnIndexes.map((item) => row[item.index]) as MongoInputValue[], documentColumns, { excludePrimaryKeys: options.excludePrimaryKeys }));
|
||||
const collection = `db.getCollection(${JSON.stringify(options.collection)})`;
|
||||
if (documents.length === 1) return `${collection}.insert(${formatMongoShellLiteral(documents[0])});`;
|
||||
return `${collection}.insertMany(${formatMongoShellLiteral(documents)});`;
|
||||
}
|
||||
|
||||
function compactLocalTimestamp(date = new Date()): string {
|
||||
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export type MongoInputValue = string | number | boolean | null;
|
|||
|
||||
const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/;
|
||||
const LEGACY_MONGO_DATE_DISPLAY_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?$/;
|
||||
const MONGO_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
|
||||
|
||||
export function mongoShellDateToExtendedJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
|
|
@ -67,6 +68,22 @@ export function buildMongoInsertDocument(row: MongoInputValue[], columns: string
|
|||
return doc;
|
||||
}
|
||||
|
||||
export function buildMongoCopyInsertDocument(row: MongoInputValue[], columns: string[], options: { excludePrimaryKeys?: boolean } = {}): Record<string, unknown> {
|
||||
const doc: Record<string, unknown> = {};
|
||||
for (let ci = 0; ci < columns.length; ci++) {
|
||||
const col = columns[ci];
|
||||
if (!col || (options.excludePrimaryKeys && col === "_id")) continue;
|
||||
const val = row[ci];
|
||||
if (val === null) continue;
|
||||
if (col === "_id" && typeof val === "string" && MONGO_OBJECT_ID_PATTERN.test(val)) {
|
||||
doc[col] = { $oid: val };
|
||||
continue;
|
||||
}
|
||||
doc[col] = parseMongoDocumentInputValue(val);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function formatMongoShellLiteral(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
|
|
@ -78,6 +95,9 @@ export function formatMongoShellLiteral(value: unknown): string {
|
|||
if (keys.length === 1 && typeof object.$date === "string") {
|
||||
return `ISODate(${JSON.stringify(object.$date)})`;
|
||||
}
|
||||
if (keys.length === 1 && typeof object.$oid === "string" && MONGO_OBJECT_ID_PATTERN.test(object.$oid)) {
|
||||
return `ObjectId(${JSON.stringify(object.$oid)})`;
|
||||
}
|
||||
return `{${keys.map((key) => `${JSON.stringify(key)}:${formatMongoShellLiteral(object[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(String(value));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongoDocumentValues.ts";
|
||||
import { buildMongoCopyInsertDocument, buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongoDocumentValues.ts";
|
||||
|
||||
test("parses Mongo shell ISODate literals as extended JSON dates", () => {
|
||||
assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), {
|
||||
|
|
@ -42,6 +42,32 @@ test("builds Mongo inserts with parsed date values", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("builds Mongo copy inserts with ObjectId and parsed document values", () => {
|
||||
assert.deepEqual(
|
||||
buildMongoCopyInsertDocument(
|
||||
["6743e4bfa3f6f84bc3fff6c8", "577", '{"endingBalance":{"beginningBalance":"0"},"Line":[]}', 'ISODate("2024-11-25T02:45:36.184Z")'],
|
||||
["_id", "accountId", "data", "lastUpdatedDate"],
|
||||
),
|
||||
{
|
||||
_id: { $oid: "6743e4bfa3f6f84bc3fff6c8" },
|
||||
accountId: 577,
|
||||
data: {
|
||||
endingBalance: {
|
||||
beginningBalance: "0",
|
||||
},
|
||||
Line: [],
|
||||
},
|
||||
lastUpdatedDate: { $date: "2024-11-25T02:45:36.184Z" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("builds Mongo copy inserts without primary keys when requested", () => {
|
||||
assert.deepEqual(buildMongoCopyInsertDocument(["6743e4bfa3f6f84bc3fff6c8", "done"], ["_id", "status"], { excludePrimaryKeys: true }), {
|
||||
status: "done",
|
||||
});
|
||||
});
|
||||
|
||||
test("formats extended JSON dates as Mongo shell ISODate literals", () => {
|
||||
assert.equal(
|
||||
formatMongoShellLiteral({
|
||||
|
|
@ -52,3 +78,7 @@ test("formats extended JSON dates as Mongo shell ISODate literals", () => {
|
|||
'{"$set":{"createdAt":ISODate("2026-06-10T13:59:31.287Z")}}',
|
||||
);
|
||||
});
|
||||
|
||||
test("formats extended JSON object ids as Mongo shell ObjectId literals", () => {
|
||||
assert.equal(formatMongoShellLiteral({ $oid: "6743e4bfa3f6f84bc3fff6c8" }), 'ObjectId("6743e4bfa3f6f84bc3fff6c8")');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const apiMock = vi.hoisted(() => ({
|
|||
exportQueryResultJson: vi.fn(),
|
||||
exportQueryResultMarkdown: vi.fn(),
|
||||
exportQueryResultsXlsx: vi.fn(),
|
||||
buildDataGridCopyInsertStatement: vi.fn(),
|
||||
}));
|
||||
const clipboardMock = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
|
|
@ -294,6 +295,90 @@ test("copy row JSON keeps nested JSON strings for non-MongoDB rows", async () =>
|
|||
});
|
||||
});
|
||||
|
||||
test("copy MongoDB row as INSERT uses Mongo shell insert syntax", async () => {
|
||||
const contextCell = ref({ rowId: 1, rowIndex: 0, col: 0 });
|
||||
const jsonString = '{"endingBalance":{"beginningBalance":"0","endingBalance":"100","endingDate":"2024-11-25"},"Line":[]}';
|
||||
const row = {
|
||||
id: 1,
|
||||
data: ["6743e4bfa3f6f84bc3fff6c8", "577", "done", jsonString, 'ISODate("2024-11-25T02:45:36.184Z")'],
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
isDirtyCol: [false, false, false, false, false],
|
||||
status: "",
|
||||
};
|
||||
const composable = useDataGridExport({
|
||||
columns: computed(() => ["_id", "accountId", "status", "data", "lastUpdatedDate"]),
|
||||
displayItems: computed(() => [row]),
|
||||
sql: computed(() => undefined),
|
||||
tableMeta: computed(() => undefined),
|
||||
copyInsertTargetLabel: computed(() => "accounting_reconciliations"),
|
||||
databaseType: computed(() => "mongodb"),
|
||||
connectionId: computed(() => "conn-1"),
|
||||
database: computed(() => "db"),
|
||||
context: computed(() => "results"),
|
||||
sourceColumns: computed(() => undefined),
|
||||
columnTypes: computed(() => undefined),
|
||||
whereInput: computed(() => undefined),
|
||||
orderBy: computed(() => undefined),
|
||||
exportBatchSize: computed(() => 1000),
|
||||
hasCellSelection: computed(() => false),
|
||||
selectedCells: computed(() => ({ columns: [], rows: [] })),
|
||||
selectedRange: computed(() => null),
|
||||
contextCell,
|
||||
getRowItem: () => row,
|
||||
selectedRowIds: ref(new Set<number>()),
|
||||
hasRowSelection: computed(() => false),
|
||||
});
|
||||
|
||||
await composable.prefetchRowAsInsertStatement(false);
|
||||
await composable.copyRowAsInsert();
|
||||
|
||||
assert.equal(apiMock.buildDataGridCopyInsertStatement.mock.calls.length, 0);
|
||||
assert.equal(
|
||||
clipboardMock.copyToClipboard.mock.calls[0][0],
|
||||
'db.getCollection("accounting_reconciliations").insert({"_id":ObjectId("6743e4bfa3f6f84bc3fff6c8"),"accountId":577,"status":"done","data":{"endingBalance":{"beginningBalance":"0","endingBalance":"100","endingDate":"2024-11-25"},"Line":[]},"lastUpdatedDate":ISODate("2024-11-25T02:45:36.184Z")});',
|
||||
);
|
||||
});
|
||||
|
||||
test("copy MongoDB rows as INSERT excludes _id for insert without primary keys", async () => {
|
||||
const selectedRowIds = ref(new Set([1, 2]));
|
||||
const rows = [
|
||||
{ id: 1, data: ["6743e4bfa3f6f84bc3fff6c8", "done"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" },
|
||||
{ id: 2, data: ["6743e4bfa3f6f84bc3fff6c9", "draft"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" },
|
||||
];
|
||||
const composable = useDataGridExport({
|
||||
columns: computed(() => ["_id", "status"]),
|
||||
displayItems: computed(() => rows),
|
||||
sql: computed(() => undefined),
|
||||
tableMeta: computed(() => ({
|
||||
tableName: "accounting_reconciliations",
|
||||
primaryKeys: ["_id"],
|
||||
})),
|
||||
databaseType: computed(() => "mongodb"),
|
||||
connectionId: computed(() => "conn-1"),
|
||||
database: computed(() => "db"),
|
||||
context: computed(() => "results"),
|
||||
sourceColumns: computed(() => undefined),
|
||||
columnTypes: computed(() => undefined),
|
||||
whereInput: computed(() => undefined),
|
||||
orderBy: computed(() => undefined),
|
||||
exportBatchSize: computed(() => 1000),
|
||||
hasCellSelection: computed(() => false),
|
||||
selectedCells: computed(() => ({ columns: [], rows: [] })),
|
||||
selectedRange: computed(() => null),
|
||||
contextCell: ref(null),
|
||||
getRowItem: (rowId: number) => rows.find((item) => item.id === rowId),
|
||||
selectedRowIds,
|
||||
hasRowSelection: computed(() => true),
|
||||
});
|
||||
|
||||
await composable.prefetchRowAsInsertStatement(true);
|
||||
await composable.copyRowAsInsertWithoutPrimaryKeys();
|
||||
|
||||
assert.equal(apiMock.buildDataGridCopyInsertStatement.mock.calls.length, 0);
|
||||
assert.equal(clipboardMock.copyToClipboard.mock.calls[0][0], 'db.getCollection("accounting_reconciliations").insertMany([{"status":"done"},{"status":"draft"}]);');
|
||||
});
|
||||
|
||||
test("default data grid export file names use sanitized base names and compact local timestamps", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue