Skip to content

Commit 20649b3

Browse files
Merge pull request #960 from QueryaHub/issue/884-staging-purge-deleted-null-sentinel
fix(grid): purge deleted rows and sanitize kNullSentinel on Save in staging buffer (#884)
2 parents 3939b04 + 20134db commit 20649b3

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)