From 01f8b9e7b6bbcde89acbe5191c3d3e380a243963 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 13:32:47 +0300 Subject: [PATCH] fix(grid): clear primary key columns when duplicating a row in staging buffer (Closes #888) Duplicate Row cloned every cell verbatim, so the staged INSERT carried the original primary key and Save failed with a unique constraint violation. Add DataGridStagingBuffer.duplicateRow, which blanks primary key cells (matched case-insensitively and ignoring identifier quoting) so the engine can omit them and let the database generate the key. The buffer now receives the table primary keys from the Table Browser views and SQL workspaces, and the grid delegates Duplicate Row to it. --- .../extensions/extension_table_view.dart | 1 + lib/features/mysql/mysql_sql_workspace.dart | 12 +++- lib/features/mysql/mysql_table_view.dart | 3 + .../postgresql/postgres_sql_workspace.dart | 12 +++- .../postgresql/postgres_table_view.dart | 2 + lib/features/sqlite/sqlite_sql_workspace.dart | 12 +++- lib/features/sqlite/sqlite_table_view.dart | 3 + .../workspace/data_grid_staging_buffer.dart | 25 +++++++++ lib/features/workspace/result_grid_view.dart | 3 +- .../workspace/table_view_staging.dart | 7 ++- .../data_grid_staging_buffer_test.dart | 56 +++++++++++++++++++ 11 files changed, 127 insertions(+), 9 deletions(-) diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 06e58fb..b4a2dac 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -315,6 +315,7 @@ class _ExtensionTableViewState extends material.State { columns: _columns, rows: _rows, enabled: editingEnabled, + primaryKeys: _primaryKeys, ); _updateStatusLine(); }); diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index ca0edef..81d9d76 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -424,7 +424,11 @@ class _MysqlSqlWorkspaceState extends material.State { session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); session.stagingBuffer = canSave - ? DataGridStagingBuffer(columns: cols, rows: outRows) + ? DataGridStagingBuffer( + columns: cols, + rows: outRows, + primaryKeys: pks, + ) : null; if (cols.isEmpty && outRows.isEmpty) { session.statusLine = affected != null @@ -543,7 +547,11 @@ class _MysqlSqlWorkspaceState extends material.State { setState(() { session.rows = newRows; session.stagingBuffer = - DataGridStagingBuffer(columns: session.columns, rows: session.rows); + DataGridStagingBuffer( + columns: session.columns, + rows: session.rows, + primaryKeys: session.resultGridPrimaryKeys, + ); session.savingChanges = false; }); } catch (e) { diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 6a152f9..c35fba5 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -146,6 +146,7 @@ class _MysqlTableViewState extends material.State { columns: _columnNames, rows: _rows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); } if (mounted) setState(() {}); @@ -300,6 +301,7 @@ class _MysqlTableViewState extends material.State { columns: columns, rows: rows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); } @@ -585,6 +587,7 @@ class _MysqlTableViewState extends material.State { columns: _columnNames, rows: newRows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); _isSaving = false; }); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 090e51c..87fc520 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -545,7 +545,11 @@ class _PostgresSqlWorkspaceState extends material.State { session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); session.stagingBuffer = canSave - ? DataGridStagingBuffer(columns: cols, rows: outRows) + ? DataGridStagingBuffer( + columns: cols, + rows: outRows, + primaryKeys: pks, + ) : null; if (cols.isEmpty && outRows.isEmpty) { session.statusLine = @@ -671,7 +675,11 @@ class _PostgresSqlWorkspaceState extends material.State { setState(() { session.rows = newRows; session.stagingBuffer = - DataGridStagingBuffer(columns: session.columns, rows: session.rows); + DataGridStagingBuffer( + columns: session.columns, + rows: session.rows, + primaryKeys: session.resultGridPrimaryKeys, + ); session.savingChanges = false; }); await _refreshTxStatus(); diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 8c4633d..4e8affb 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -256,6 +256,7 @@ class _PostgresTableViewState extends material.State { columns: columns, rows: rows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); } @@ -606,6 +607,7 @@ class _PostgresTableViewState extends material.State { columns: _columnNames, rows: newRows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); _isSaving = false; }); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 22bfc07..c28bc53 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -395,7 +395,11 @@ class _SqliteSqlWorkspaceState extends material.State { session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); session.stagingBuffer = canSave - ? DataGridStagingBuffer(columns: cols, rows: outRows) + ? DataGridStagingBuffer( + columns: cols, + rows: outRows, + primaryKeys: pks, + ) : null; if (cols.isEmpty && outRows.isEmpty) { session.statusLine = 'Command completed.'; @@ -509,7 +513,11 @@ class _SqliteSqlWorkspaceState extends material.State { setState(() { session.rows = newRows; session.stagingBuffer = - DataGridStagingBuffer(columns: session.columns, rows: session.rows); + DataGridStagingBuffer( + columns: session.columns, + rows: session.rows, + primaryKeys: session.resultGridPrimaryKeys, + ); session.savingChanges = false; }); } catch (e) { diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 0cd9d0f..a9801d5 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -136,6 +136,7 @@ class _SqliteTableViewState extends material.State { columns: _columnNames, rows: _rows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); } if (mounted) setState(() {}); @@ -262,6 +263,7 @@ class _SqliteTableViewState extends material.State { columns: columns, rows: rows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); } @@ -408,6 +410,7 @@ class _SqliteTableViewState extends material.State { columns: _columnNames, rows: newRows, enabled: _editingEnabled, + primaryKeys: _primaryKeys, ); _isSaving = false; }); diff --git a/lib/features/workspace/data_grid_staging_buffer.dart b/lib/features/workspace/data_grid_staging_buffer.dart index acaaab7..c0f07a3 100644 --- a/lib/features/workspace/data_grid_staging_buffer.dart +++ b/lib/features/workspace/data_grid_staging_buffer.dart @@ -26,7 +26,9 @@ class DataGridStagingBuffer extends ChangeNotifier { DataGridStagingBuffer({ required List columns, required List> rows, + List primaryKeys = const [], }) : _originalColumns = List.unmodifiable(columns), + _primaryKeys = List.unmodifiable(primaryKeys), _originalRows = List.unmodifiable( rows.map((r) => List.unmodifiable(r)).toList(), ) { @@ -36,6 +38,10 @@ class DataGridStagingBuffer extends ChangeNotifier { final List _originalColumns; final List> _originalRows; + /// Primary key column names; blanked when a row is duplicated so the + /// database can generate a fresh key instead of hitting a unique violation. + final List _primaryKeys; + /// Map of `rowIndex -> (colIndex -> stagedValue)` for modified cells in baseline rows. final Map> _modifiedCells = {}; @@ -322,6 +328,25 @@ class DataGridStagingBuffer extends ChangeNotifier { ); } + /// Stages a copy of the row at [index] (baseline or already inserted) as a + /// new row with its primary key cells blanked. + /// + /// Composite or natural keys the database cannot generate stay blank for the + /// user to fill in before saving. No-op when [index] is out of range. + void duplicateRow(int index) { + if (index < 0 || index >= totalRowCount) return; + final pkNames = _primaryKeys + .map((pk) => TableMutationEngine.unquoteIdentifier(pk).toLowerCase()) + .toSet(); + final row = List.generate(_originalColumns.length, (c) { + final name = TableMutationEngine.unquoteIdentifier(_originalColumns[c]) + .toLowerCase(); + if (pkNames.contains(name)) return ''; + return _sanitizeNull(getCellValue(index, c)); + }); + addRow(row); + } + /// Returns the full list of effective rows (original with modifications applied + inserted rows). /// /// Row indices stay aligned with the grid (rows marked for deletion are still diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 19d3363..9712b03 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -1509,8 +1509,7 @@ class _VirtualResultGridState extends material.State { void _handleDuplicateRow(int row) { if (widget.stagingBuffer == null || row >= _sortedRows.length) return; - final rowData = _sortedRows[row]; - widget.stagingBuffer!.addRow(List.from(rowData)); + widget.stagingBuffer!.duplicateRow(_toModelRowIndex(row)); } void _handleToggleDeleteRow(int row) { diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 7c019ab..770a276 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -90,10 +90,15 @@ DataGridStagingBuffer? replaceTableViewStagingBuffer({ required List columns, required List> rows, required bool enabled, + List primaryKeys = const [], }) { previous?.dispose(); if (!enabled || columns.isEmpty) return null; - return DataGridStagingBuffer(columns: columns, rows: rows); + return DataGridStagingBuffer( + columns: columns, + rows: rows, + primaryKeys: primaryKeys, + ); } /// Confirms discarding dirty staged edits. Returns true when it is safe to proceed. diff --git a/test/features/workspace/data_grid_staging_buffer_test.dart b/test/features/workspace/data_grid_staging_buffer_test.dart index b0a14dd..1d9aaf7 100644 --- a/test/features/workspace/data_grid_staging_buffer_test.dart +++ b/test/features/workspace/data_grid_staging_buffer_test.dart @@ -153,6 +153,62 @@ void main() { ]); }); + test('duplicateRow blanks primary key cells and keeps other values', () { + final keyed = DataGridStagingBuffer( + columns: ['id', 'name', 'email'], + rows: [ + ['42', 'Alice', 'alice@test.com'], + ], + primaryKeys: ['id'], + ); + addTearDown(keyed.dispose); + keyed.setCell(0, 1, 'Alice Edited'); + + keyed.duplicateRow(0); + + expect(keyed.insertedRowCount, 1); + expect(keyed.effectiveRows.last, ['', 'Alice Edited', 'alice@test.com']); + expect(keyed.getRowStatus(1), StagedRowStatus.inserted); + }); + + test( + 'duplicateRow blanks every column of a composite key, case-insensitively', + () { + final keyed = DataGridStagingBuffer( + columns: ['Org', 'user_id', 'role'], + rows: [ + ['1', '2', 'admin'], + ], + primaryKeys: ['org', '"user_id"'], + ); + addTearDown(keyed.dispose); + + keyed.duplicateRow(0); + + expect(keyed.effectiveRows.last, ['', '', 'admin']); + }); + + test('duplicateRow copies staged NULLs as NULL and can copy inserted rows', + () { + buffer.setCellNull(0, 2); + buffer.duplicateRow(0); + expect(buffer.effectiveRows.last, ['1', 'Alice', 'NULL']); + + buffer.duplicateRow(3); // the row inserted above + expect(buffer.insertedRowCount, 2); + }); + + test( + 'duplicateRow without primary keys copies verbatim; bad index is a no-op', + () { + buffer.duplicateRow(1); + expect(buffer.effectiveRows.last, ['2', 'Bob', 'bob@test.com']); + + buffer.duplicateRow(-1); + buffer.duplicateRow(99); + expect(buffer.insertedRowCount, 1); + }); + test('committedRows equals baseline when clean', () { expect(buffer.committedRows, buffer.originalRows); });