fix(mongodb): honor selected columns in update copy

This commit is contained in:
zipg 2026-08-02 19:09:22 +08:00 committed by GitHub
parent 2ac8799cb0
commit 918c2fdf7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 164 additions and 12 deletions

View File

@ -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();
});
});

View File

@ -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,
});

View File

@ -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 };
}