fix(mongodb): honor selected columns in update copy
This commit is contained in:
parent
2ac8799cb0
commit
918c2fdf7a
|
|
@ -691,4 +691,112 @@ describe("useDataGridExport prepared row statements", () => {
|
|||
);
|
||||
expect(extractDataGridSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the Mongo update formatter for SQL Updates", async () => {
|
||||
const item = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice"]), sourceIndex: 0 };
|
||||
const state = createMongoExportState({
|
||||
columns: ["_id", "name"],
|
||||
item,
|
||||
mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }],
|
||||
selectedCellMatrix: {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [0, 1],
|
||||
columns: ["_id", "name"],
|
||||
rows: [[item.data[0], item.data[1]]],
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.canCopyWithExtractor("sql-updates")).toBe(true);
|
||||
await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(true);
|
||||
|
||||
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('"name": "Alice"');
|
||||
expect(extractDataGridSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates only explicitly selected Mongo fields while keeping _id as the filter", async () => {
|
||||
const item = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice", "active"]), sourceIndex: 0 };
|
||||
const state = createMongoExportState({
|
||||
columns: ["_id", "name", "status"],
|
||||
item,
|
||||
mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice", status: "active" }],
|
||||
selectedCellMatrix: {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [1],
|
||||
columns: ["name"],
|
||||
rows: [[item.data[1]]],
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.canCopyWithExtractor("sql-updates")).toBe(true);
|
||||
await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(true);
|
||||
|
||||
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain('"_id": ObjectId("507f1f77bcf86cd799439011")');
|
||||
expect(copied).toContain('"name": "Alice"');
|
||||
expect(copied).not.toContain('"status"');
|
||||
});
|
||||
|
||||
it("does not expose Mongo SQL Updates for an _id-only selection", async () => {
|
||||
const item = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice"]), sourceIndex: 0 };
|
||||
const state = createMongoExportState({
|
||||
columns: ["_id", "name"],
|
||||
item,
|
||||
mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }],
|
||||
selectedCellMatrix: {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [0],
|
||||
columns: ["_id"],
|
||||
rows: [[item.data[0]]],
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.canCopyWithExtractor("sql-updates")).toBe(false);
|
||||
await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(false);
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters new and deleted Mongo rows from SQL Updates", async () => {
|
||||
const current = { ...row(['ObjectId("507f1f77bcf86cd799439011")', "Alice"]), id: 1, sourceIndex: 0 };
|
||||
const added = { ...row(['ObjectId("507f1f77bcf86cd799439012")', "New"]), id: 2, sourceIndex: 1, isNew: true };
|
||||
const deleted = { ...row(['ObjectId("507f1f77bcf86cd799439013")', "Deleted"]), id: 3, sourceIndex: 2, isDeleted: true };
|
||||
const state = createMongoExportState({
|
||||
columns: ["_id", "name"],
|
||||
item: current,
|
||||
items: [current, added, deleted],
|
||||
mongoDocuments: [
|
||||
{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" },
|
||||
{ _id: { $oid: "507f1f77bcf86cd799439012" }, name: "New" },
|
||||
{ _id: { $oid: "507f1f77bcf86cd799439013" }, name: "Deleted" },
|
||||
],
|
||||
selectedRowIds: new Set([1, 2, 3]),
|
||||
});
|
||||
|
||||
expect(state.canCopyWithExtractor("sql-updates")).toBe(true);
|
||||
await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(true);
|
||||
|
||||
const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied.match(/\.updateOne\(/g)).toHaveLength(1);
|
||||
expect(copied).toContain('"name": "Alice"');
|
||||
expect(copied).not.toContain('"name": "New"');
|
||||
expect(copied).not.toContain('"name": "Deleted"');
|
||||
});
|
||||
|
||||
it("does not expose Mongo SQL Updates without an explicit update 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.canCopyWithExtractor("sql-updates")).toBe(false);
|
||||
await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(false);
|
||||
expect(extractDataGridSelection).not.toHaveBeenCalled();
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
|||
import { saveTextFile, sanitizeExportBaseName, compactLocalTimestamp } from "@/lib/export/saveTextFile";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
|
||||
import type { DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import type { DataGridExtractRequest, DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
|
||||
|
|
@ -469,6 +469,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
return targetedRows().filter((item) => !item.isDraft);
|
||||
}
|
||||
|
||||
function updateEligibleRows(): RowItem[] {
|
||||
return targetedRows().filter((item) => !item.isNew && !item.isDraft && !item.isDeleted);
|
||||
}
|
||||
|
||||
function insertableCopyColumnCount(excludePrimaryKeys: boolean, copyColumns = effectiveColumns(sourceColumns.value, columns.value), extractorOptions?: DataGridExtractorOptions): number {
|
||||
const primaryKeySet = new Set((tableMeta.value?.primaryKeys ?? []).map(normalizeColumnName));
|
||||
return copyColumns.filter((column): column is string => !!column && !isCopyInsertOmittedColumn(databaseType.value, column, tableMeta.value, extractorOptions) && (!excludePrimaryKeys || !primaryKeySet.has(normalizeColumnName(column)))).length;
|
||||
|
|
@ -489,14 +493,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
return buildCopyInsertStatement(rowLimit === undefined ? data : { ...data, rows: data.rows.slice(0, rowLimit) }, extractorOptions.sql.excludePrimaryKeysFromInsert, extractorOptions.sql.insertMode);
|
||||
}
|
||||
|
||||
async function buildMongoExtractorUpdate(_extractorOptions: DataGridExtractorOptions, rowLimit?: number): Promise<string | undefined> {
|
||||
function mongoUpdateColumnIndexes(request: DataGridExtractRequest): number[] {
|
||||
const selectedColumns = new Set(
|
||||
request.selectedColumnIndexes
|
||||
.map((index) => request.columns[index]?.sourceName ?? request.columns[index]?.displayName)
|
||||
.filter((column): column is string => !!column)
|
||||
.map(normalizeColumnName),
|
||||
);
|
||||
return effectiveColumns(sourceColumns.value, columns.value)
|
||||
.map((column, index) => (column && selectedColumns.has(normalizeColumnName(column)) ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
}
|
||||
|
||||
async function buildMongoExtractorUpdate(request: DataGridExtractRequest, rowLimit?: number): Promise<string | undefined> {
|
||||
const target = options.mongoUpdateTarget?.value;
|
||||
const documents = options.mongoDocuments?.value;
|
||||
if (!target || !documents) return undefined;
|
||||
const rows = insertEligibleRows();
|
||||
const rows = updateEligibleRows();
|
||||
if (rows.length === 0) return undefined;
|
||||
const limitedRows = rowLimit === undefined ? rows : rows.slice(0, rowLimit);
|
||||
const copyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? "");
|
||||
const allCopyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? "");
|
||||
const selectedColumnIndexes = mongoUpdateColumnIndexes(request);
|
||||
const copyColumns = selectedColumnIndexes.map((index) => allCopyColumns[index]);
|
||||
await yieldToMainThread();
|
||||
const statements: string[] = [];
|
||||
for (const item of limitedRows) {
|
||||
|
|
@ -505,7 +523,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
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);
|
||||
const update = buildMongoCopyUpdateDocument(
|
||||
selectedColumnIndexes.map((index) => item.data[index]) as MongoInputValue[],
|
||||
copyColumns,
|
||||
selectedColumnIndexes.map((index) => item.isDirtyCol[index] ?? false),
|
||||
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);
|
||||
|
|
@ -513,6 +537,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
return statements.length > 0 ? statements.join("\n") : undefined;
|
||||
}
|
||||
|
||||
function canBuildMongoExtractorUpdate(request: DataGridExtractRequest): boolean {
|
||||
const target = options.mongoUpdateTarget?.value;
|
||||
const documents = options.mongoDocuments?.value;
|
||||
const rows = updateEligibleRows();
|
||||
if (!target || !documents || rows.length === 0) return false;
|
||||
|
||||
const normalizedIdColumn = normalizeColumnName(target.idColumn);
|
||||
const copyColumns = mongoUpdateColumnIndexes(request).map((index) => effectiveColumns(sourceColumns.value, columns.value)[index] ?? "");
|
||||
if (!copyColumns.some((column) => column && normalizeColumnName(column) !== normalizedIdColumn)) return false;
|
||||
|
||||
return rows.every((item) => {
|
||||
if (item.sourceIndex === undefined) return false;
|
||||
const document = documents[item.sourceIndex];
|
||||
return !!document && typeof document === "object" && !Array.isArray(document) && Object.prototype.hasOwnProperty.call(document, target.idColumn);
|
||||
});
|
||||
}
|
||||
|
||||
const { copyWithExtractor, previewWithExtractor, canCopyWithExtractor } = useDataGridExtractor({
|
||||
columns,
|
||||
displayItems,
|
||||
|
|
@ -537,6 +578,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
},
|
||||
buildMongoInsert: buildMongoExtractorInsert,
|
||||
buildMongoUpdate: buildMongoExtractorUpdate,
|
||||
canBuildMongoUpdate: canBuildMongoExtractorUpdate,
|
||||
contextCell,
|
||||
contextSelectionIsSynthetic,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ interface UseDataGridExtractorOptions {
|
|||
copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise<boolean>;
|
||||
canCopySqlInsert: (request: DataGridExtractRequest) => boolean;
|
||||
buildMongoInsert: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise<string | undefined>;
|
||||
buildMongoUpdate?: (extractorOptions: DataGridExtractorOptions, rowLimit?: number) => Promise<string | undefined>;
|
||||
buildMongoUpdate?: (request: DataGridExtractRequest, rowLimit?: number) => Promise<string | undefined>;
|
||||
canBuildMongoUpdate?: (request: DataGridExtractRequest) => boolean;
|
||||
}
|
||||
|
||||
export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
|
|
@ -183,18 +184,19 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
|||
}
|
||||
if (extractor === "sql-updates") {
|
||||
// Mongo has a dedicated updateOne path that doesn't need SQL primary keys.
|
||||
if (options.databaseType.value === "mongodb") return !!options.buildMongoUpdate;
|
||||
if (options.databaseType.value === "mongodb") {
|
||||
const request = buildRequest(extractor, extractorOptions);
|
||||
return request !== null && (options.canBuildMongoUpdate?.(request) ?? false);
|
||||
}
|
||||
return canBuildSqlUpdateRequest();
|
||||
}
|
||||
return selectionData() !== null;
|
||||
}
|
||||
|
||||
async function resolveMongoExtractorResult(extractor: DataGridCopyExtractorId, request: DataGridExtractRequest, rowLimit?: number) {
|
||||
if (options.databaseType.value !== "mongodb") return null;
|
||||
const builder = extractor === "sql-inserts" ? options.buildMongoInsert : extractor === "sql-updates" ? options.buildMongoUpdate : undefined;
|
||||
if (!builder) return null;
|
||||
const text = (await builder(request.options, rowLimit)) ?? "";
|
||||
if (!text) return null;
|
||||
if (options.databaseType.value !== "mongodb") return undefined;
|
||||
if (extractor !== "sql-inserts" && extractor !== "sql-updates") return undefined;
|
||||
const text = extractor === "sql-inserts" ? ((await options.buildMongoInsert(request.options, rowLimit)) ?? "") : ((await options.buildMongoUpdate?.(request, rowLimit)) ?? "");
|
||||
return { text, mimeType: "application/javascript", fileExtension: "js", rowCount: rowLimit ?? request.rows.length, columnCount: request.selectedColumnIndexes.length, warnings: undefined, omittedColumns: undefined };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue