Skip to content

Commit 20134db

Browse files
fix(grid): purge deleted rows and sanitize kNullSentinel after Save in staging buffer (Closes #884)
After a successful Save the new baseline was built from effectiveRows, which kept rows marked for deletion and leaked the raw NULL sentinel into cells. Add DataGridStagingBuffer.committedRows (deleted baseline rows dropped, staged NULLs mapped to 'NULL') and use it as the post-save baseline in the Postgres/MySQL/SQLite table views and SQL workspaces. effectiveRows keeps row indices aligned with the grid for struck-through rendering but now also maps the sentinel to 'NULL'.
1 parent 3939b04 commit 20134db

9 files changed

Lines changed: 136 additions & 22 deletions

‎lib/features/mysql/mysql_sql_workspace.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
538538
await _refreshTxStatus();
539539

540540
if (!mounted) return;
541-
final newRows = session.stagingBuffer!.effectiveRows;
541+
final newRows = session.stagingBuffer!.committedRows;
542542
session.stagingBuffer?.dispose();
543543
setState(() {
544544
session.rows = newRows;

‎lib/features/mysql/mysql_table_view.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
575575
);
576576
if (!mounted) return;
577577
if (outcome.isApplied) {
578-
final newRows = buffer.effectiveRows;
578+
final newRows = buffer.committedRows;
579579
buffer.dispose();
580580
setState(() {
581581
_rows = newRows;

‎lib/features/postgresql/postgres_sql_workspace.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
666666
);
667667

668668
if (!mounted) return;
669-
final newRows = session.stagingBuffer!.effectiveRows;
669+
final newRows = session.stagingBuffer!.committedRows;
670670
session.stagingBuffer?.dispose();
671671
setState(() {
672672
session.rows = newRows;

‎lib/features/postgresql/postgres_table_view.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
596596
);
597597
if (!mounted) return;
598598
if (outcome.isApplied) {
599-
final newRows = buffer.effectiveRows;
599+
final newRows = buffer.committedRows;
600600
buffer.dispose();
601601
setState(() {
602602
_rows = newRows;

‎lib/features/sqlite/sqlite_sql_workspace.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
504504
await _refreshTxStatus();
505505

506506
if (!mounted) return;
507-
final newRows = session.stagingBuffer!.effectiveRows;
507+
final newRows = session.stagingBuffer!.committedRows;
508508
session.stagingBuffer?.dispose();
509509
setState(() {
510510
session.rows = newRows;

‎lib/features/sqlite/sqlite_table_view.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
398398
);
399399
if (!mounted) return;
400400
if (outcome.isApplied) {
401-
final newRows = buffer.effectiveRows;
401+
final newRows = buffer.committedRows;
402402
buffer.dispose();
403403
setState(() {
404404
_rows = newRows;

‎lib/features/workspace/data_grid_staging_buffer.dart‎

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ class DataGridStagingBuffer extends ChangeNotifier {
9898
return '';
9999
}
100100
final insertIdx = row - _originalRows.length;
101-
if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) {
101+
if (insertIdx < _insertedRows.length &&
102+
col < _insertedRows[insertIdx].length) {
102103
final ins = _insertedRows[insertIdx][col];
103104
return ins == TableMutationEngine.kNullSentinel ? 'NULL' : ins;
104105
}
@@ -118,7 +119,8 @@ class DataGridStagingBuffer extends ChangeNotifier {
118119
return false;
119120
}
120121
final insertIdx = row - _originalRows.length;
121-
if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) {
122+
if (insertIdx < _insertedRows.length &&
123+
col < _insertedRows[insertIdx].length) {
122124
final ins = _insertedRows[insertIdx][col];
123125
return ins == 'NULL' || ins == TableMutationEngine.kNullSentinel;
124126
}
@@ -127,7 +129,10 @@ class DataGridStagingBuffer extends ChangeNotifier {
127129

128130
/// Returns original baseline cell value, or null if row is inserted.
129131
String? getOriginalCellValue(int row, int col) {
130-
if (row >= 0 && row < _originalRows.length && col >= 0 && col < _originalRows[row].length) {
132+
if (row >= 0 &&
133+
row < _originalRows.length &&
134+
col >= 0 &&
135+
col < _originalRows[row].length) {
131136
return _originalRows[row][col];
132137
}
133138
return null;
@@ -169,7 +174,8 @@ class DataGridStagingBuffer extends ChangeNotifier {
169174
if (row < 0 || col < 0) return;
170175

171176
if (row < _originalRows.length) {
172-
final orig = col < _originalRows[row].length ? _originalRows[row][col] : '';
177+
final orig =
178+
col < _originalRows[row].length ? _originalRows[row][col] : '';
173179
if (value == orig) {
174180
if (_modifiedCells.containsKey(row)) {
175181
_modifiedCells[row]!.remove(col);
@@ -281,7 +287,8 @@ class DataGridStagingBuffer extends ChangeNotifier {
281287
/// Read-only snapshot of modified cells mapping.
282288
Map<int, Map<int, String>> get modifiedCells =>
283289
Map<int, Map<int, String>>.unmodifiable(
284-
_modifiedCells.map((k, v) => MapEntry(k, Map<int, String>.unmodifiable(v))),
290+
_modifiedCells
291+
.map((k, v) => MapEntry(k, Map<int, String>.unmodifiable(v))),
285292
);
286293

287294
/// Read-only list of newly inserted rows.
@@ -317,6 +324,10 @@ class DataGridStagingBuffer extends ChangeNotifier {
317324

318325
/// Returns the full list of effective rows (original with modifications applied + inserted rows).
319326
///
327+
/// Row indices stay aligned with the grid (rows marked for deletion are still
328+
/// present so they can render struck through); use [committedRows] for the
329+
/// baseline after a successful save. Staged NULLs are shown as `'NULL'`.
330+
///
320331
/// Optimized for zero allocations when [isDirty] is false, and caches
321332
/// the effective row list to prevent breaking widget memoization.
322333
List<List<String>> get effectiveRows {
@@ -326,14 +337,27 @@ class DataGridStagingBuffer extends ChangeNotifier {
326337
if (_cachedEffectiveRows != null) {
327338
return _cachedEffectiveRows!;
328339
}
340+
_cachedEffectiveRows = List.unmodifiable(_buildRows(includeDeleted: true));
341+
return _cachedEffectiveRows!;
342+
}
343+
344+
/// Rows as they exist in the database once the staged changes are applied:
345+
/// deleted baseline rows are dropped and staged NULLs become `'NULL'`.
346+
///
347+
/// Use this as the new baseline after a successful save.
348+
List<List<String>> get committedRows =>
349+
List.unmodifiable(_buildRows(includeDeleted: false));
350+
351+
List<List<String>> _buildRows({required bool includeDeleted}) {
329352
final result = <List<String>>[];
330353
for (var r = 0; r < _originalRows.length; r++) {
354+
if (!includeDeleted && _deletedRowIndices.contains(r)) continue;
331355
final mods = _modifiedCells[r];
332356
if (mods != null) {
333357
final row = List<String>.from(_originalRows[r]);
334358
for (final entry in mods.entries) {
335359
if (entry.key < row.length) {
336-
row[entry.key] = entry.value;
360+
row[entry.key] = _sanitizeNull(entry.value);
337361
}
338362
}
339363
result.add(row);
@@ -342,12 +366,18 @@ class DataGridStagingBuffer extends ChangeNotifier {
342366
}
343367
}
344368
for (final ins in _insertedRows) {
345-
result.add(ins);
369+
result.add(
370+
ins.any((v) => v == TableMutationEngine.kNullSentinel)
371+
? ins.map(_sanitizeNull).toList()
372+
: ins,
373+
);
346374
}
347-
_cachedEffectiveRows = List.unmodifiable(result);
348-
return _cachedEffectiveRows!;
375+
return result;
349376
}
350377

378+
static String _sanitizeNull(String value) =>
379+
value == TableMutationEngine.kNullSentinel ? 'NULL' : value;
380+
351381
@override
352382
void dispose() {
353383
UnsavedWorkRegistry.instance.unregister(this);
@@ -358,4 +388,3 @@ class DataGridStagingBuffer extends ChangeNotifier {
358388
super.dispose();
359389
}
360390
}
361-

‎test/features/workspace/data_grid_staging_buffer_test.dart‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,45 @@ void main() {
121121
expect(eff[3], ['4', 'David', 'david@test.com']);
122122
});
123123

124-
test('effectiveRows returns identical reference when clean and caches unmodifiable list when dirty', () {
124+
test(
125+
'effectiveRows keeps deleted rows aligned and maps staged NULL to NULL',
126+
() {
127+
buffer.toggleDeleteRow(1);
128+
buffer.setCellNull(0, 2);
129+
buffer.addRow(['4', TableMutationEngine.kNullSentinel, 'd@test.com']);
130+
131+
final eff = buffer.effectiveRows;
132+
expect(eff.length, 4);
133+
expect(eff[0], ['1', 'Alice', 'NULL']);
134+
expect(eff[1], ['2', 'Bob', 'bob@test.com']);
135+
expect(eff[3], ['4', 'NULL', 'd@test.com']);
136+
expect(
137+
eff.expand((r) => r).any((v) => v.contains('\u0000')),
138+
isFalse,
139+
);
140+
});
141+
142+
test('committedRows drops deleted rows and sanitizes NULL sentinels', () {
143+
buffer.toggleDeleteRow(1);
144+
buffer.setCell(1, 1, 'ignored'); // edit on a row that is also deleted
145+
buffer.setCellNull(0, 2);
146+
buffer.addRow(['4', TableMutationEngine.kNullSentinel, 'd@test.com']);
147+
148+
final committed = buffer.committedRows;
149+
expect(committed, [
150+
['1', 'Alice', 'NULL'],
151+
['3', 'Charlie', 'charlie@test.com'],
152+
['4', 'NULL', 'd@test.com'],
153+
]);
154+
});
155+
156+
test('committedRows equals baseline when clean', () {
157+
expect(buffer.committedRows, buffer.originalRows);
158+
});
159+
160+
test(
161+
'effectiveRows returns identical reference when clean and caches unmodifiable list when dirty',
162+
() {
125163
// When clean, effectiveRows returns the exact baseline instance (zero allocation)
126164
final cleanRows1 = buffer.effectiveRows;
127165
final cleanRows2 = buffer.effectiveRows;
@@ -160,7 +198,9 @@ void main() {
160198
expect(buffer.getCellValue(0, 1), 'NULL');
161199
});
162200

163-
test('generateMutationPlan builds correct DML statements from staged modifications', () {
201+
test(
202+
'generateMutationPlan builds correct DML statements from staged modifications',
203+
() {
164204
buffer.setCell(0, 1, 'Alice Updated');
165205
buffer.addRow(['4', 'Diana', 'diana@test.com']);
166206
buffer.toggleDeleteRow(2);
@@ -174,14 +214,19 @@ void main() {
174214

175215
expect(plan.statementCount, 3);
176216
expect(plan.statements[0].type, MutationType.update);
177-
expect(plan.statements[0].sql, 'UPDATE "public"."users" SET "name" = \'Alice Updated\' WHERE "id" = 1');
217+
expect(plan.statements[0].sql,
218+
'UPDATE "public"."users" SET "name" = \'Alice Updated\' WHERE "id" = 1');
178219
expect(plan.statements[1].type, MutationType.insert);
179-
expect(plan.statements[1].sql, 'INSERT INTO "public"."users" ("id", "name", "email") VALUES (4, \'Diana\', \'diana@test.com\')');
220+
expect(plan.statements[1].sql,
221+
'INSERT INTO "public"."users" ("id", "name", "email") VALUES (4, \'Diana\', \'diana@test.com\')');
180222
expect(plan.statements[2].type, MutationType.delete);
181-
expect(plan.statements[2].sql, 'DELETE FROM "public"."users" WHERE "id" = 3');
223+
expect(plan.statements[2].sql,
224+
'DELETE FROM "public"."users" WHERE "id" = 3');
182225
});
183226

184-
test('dispose clears internal collections and prevents further listener notifications', () {
227+
test(
228+
'dispose clears internal collections and prevents further listener notifications',
229+
() {
185230
buffer.setCell(0, 1, 'Alice Modified');
186231
buffer.addRow(['4', 'Diana', 'diana@test.com']);
187232
buffer.toggleDeleteRow(2);

‎test/features/workspace/table_view_staging_test.dart‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,46 @@ void main() {
347347
expect(executed, isFalse);
348348
});
349349

350+
testWidgets(
351+
'applied save yields a baseline without deleted rows or NULL sentinels',
352+
(tester) async {
353+
await tester.pumpWidget(
354+
ShadcnApp(
355+
theme: AppTheme.dark,
356+
home: const material.Scaffold(body: material.SizedBox()),
357+
),
358+
);
359+
final ctx = tester.element(find.byType(material.Scaffold));
360+
final buffer = DataGridStagingBuffer(
361+
columns: ['id', 'name'],
362+
rows: [
363+
['1', 'Ada'],
364+
['2', 'Grace'],
365+
],
366+
);
367+
buffer.toggleDeleteRow(1);
368+
buffer.setCellNull(0, 1);
369+
addTearDown(buffer.dispose);
370+
371+
final future = applyTableViewStagedChanges(
372+
context: ctx,
373+
buffer: buffer,
374+
dialect: SqlDialect.postgres,
375+
tableName: 'users',
376+
schema: 'public',
377+
primaryKeys: ['id'],
378+
execute: (_) async {},
379+
);
380+
await tester.pumpAndSettle();
381+
await tester.tap(find.text('Apply Changes'));
382+
final outcome = await future;
383+
384+
expect(outcome.isApplied, isTrue);
385+
expect(buffer.committedRows, [
386+
['1', 'NULL'],
387+
]);
388+
});
389+
350390
testWidgets('0-row DML is failed and the staging buffer stays dirty',
351391
(tester) async {
352392
await tester.pumpWidget(

0 commit comments

Comments
 (0)