diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index a250a0257b..c9b8c1f4f0 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -36,7 +36,15 @@ vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({}), })); -function createEditor(sourceColumns?: Array, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[], existingRows: CellValue[][] = [], onCellValueChanged?: (rowId: number, columnIndex: number) => void) { +function createEditor( + sourceColumns?: Array, + confirmDangerousRowDeletion = true, + cacheKey?: string, + readonlyColumnIndexes?: number[], + existingRows: CellValue[][] = [], + onCellValueChanged?: (rowId: number, columnIndex: number) => void, + tableColumns?: Array<{ name: string; data_type: string; extra?: string; column_default?: string }>, +) { let editor: ReturnType; const result = ref<{ columns: string[]; rows: CellValue[][] }>({ columns: ["first", "hidden", "last"], @@ -51,7 +59,7 @@ function createEditor(sourceColumns?: Array, confirmDangerou database: computed(() => "app"), tableMeta: computed(() => ({ tableName: "people", - columns: [ + columns: tableColumns ?? [ { name: "first", data_type: "varchar" }, { name: "hidden", data_type: "varchar" }, { name: "last", data_type: "varchar" }, @@ -388,6 +396,29 @@ describe("useDataGridEditor appendPastedRowsToNewRow", () => { expect(editor.hasPendingChanges.value).toBe(true); }); + it("clears generated key columns instead of pasting the copied value", () => { + const editor = createEditor(undefined, true, undefined, undefined, [], undefined, [ + { name: "first", data_type: "integer", extra: "autoincrement" }, + { name: "hidden", data_type: "varchar" }, + { name: "last", data_type: "varchar" }, + ]); + + const result = editor.appendPastedRowsToNewRow( + -1, + [ + ["1", "Lovelace"], + ["2", "Hopper"], + ], + [0, 2], + ); + + expect(result).toEqual({ ok: true, rowCount: 2 }); + expect(editor.newRows.value).toEqual([ + [null, null, "Lovelace"], + [null, null, "Hopper"], + ]); + }); + it("keeps explicitly read-only mapped columns out of editing and paste", () => { const editor = createEditor(["first", "hidden", "last"], true, undefined, [0]); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 4bfef5971e..8659a81fc9 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1167,10 +1167,16 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { reusableNewRowCount++; } } + const shouldClearColumn = clonedColumnClearPredicate(); const mappedRows = pastedRows.map((pastedRow, rowIndex) => { const nextRow = rowIndex < reusableNewRowCount ? nextRows[targetNewIndex! + rowIndex]! : emptyDraftRow(); for (let columnOffset = 0; columnOffset < Math.min(pastedRow.length, targetColumns.length); columnOffset++) { const columnIndex = targetColumns[columnOffset]!; + // Pasting a copied row into a new row must not reuse its generated key. + if (shouldClearColumn(columnIndex)) { + nextRow[columnIndex] = null; + continue; + } const value = pastedRow[columnOffset]; nextRow[columnIndex] = value === null ? null : coerceCellValue(value, nextRow[columnIndex], columnIndex); } @@ -1205,15 +1211,26 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function clonedRowData(item: RowItem, resolvedValues?: ReadonlyMap): CellValue[] { - const columnInfoByName = new Map((tableMeta.value?.columns ?? []).map((column) => [column.name.toLowerCase(), column])); + const shouldClearColumn = clonedColumnClearPredicate(); return item.data.map((val, i) => { - const columnName = sourceColumns.value?.[i] ?? result.value.columns[i]; - const columnInfo = columnInfoByName.get(columnName.toLowerCase()); - if (shouldClearClonedColumn(columnName, columnInfo)) return null; + if (shouldClearColumn(i)) return null; return resolvedValues?.has(i) ? (resolvedValues.get(i) ?? null) : val; }); } + // Columns generated by the database (auto increment / identity / sequence + // defaults) must not be carried over when a row's values are copied into a + // new row, otherwise saving fails on a duplicate key. Both the clone-row and + // the clipboard-paste paths resolve the columns through this predicate. + function clonedColumnClearPredicate(): (columnIndex: number) => boolean { + const columnInfoByName = new Map((tableMeta.value?.columns ?? []).map((column) => [column.name.toLowerCase(), column])); + return (columnIndex: number) => { + const columnName = sourceColumns.value?.[columnIndex] ?? result.value.columns[columnIndex]; + if (columnName === undefined) return false; + return shouldClearClonedColumn(columnName, columnInfoByName.get(columnName.toLowerCase())); + }; + } + function shouldClearClonedColumn(columnName: string, columnInfo: ColumnInfo | undefined): boolean { if (usesSyntheticRowIdKey(resolvedDatabaseType.value, [columnName])) return true; if (resolvedDatabaseType.value === "neo4j" && columnName === DBX_NEO4J_ELEMENT_ID_COLUMN) return true;