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