From deedb6d671e7a7165fb135c622e26fd543c781bd Mon Sep 17 00:00:00 2001 From: Pierre Sucker Date: Fri, 24 Jul 2026 13:22:38 +0200 Subject: [PATCH 1/2] fix: clean update mask paths that are missing from the saved data --- .../services/yust_database_service_dart.dart | 15 ++++++-- .../yust_database_service_flutter.dart | 7 ++++ .../yust_database_service_shared.dart | 38 +++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/lib/src/services/yust_database_service_dart.dart b/lib/src/services/yust_database_service_dart.dart index c05c5c31..bfc4f976 100644 --- a/lib/src/services/yust_database_service_dart.dart +++ b/lib/src/services/yust_database_service_dart.dart @@ -847,6 +847,14 @@ class YustDatabaseService implements IYustDatabaseService { if (updateMask != null) updateMask.addAll(yustUpdateMask); final jsonDoc = doc.toJson(); + + if (updateMask != null) { + final cleaned = cleanUpdateMask(jsonDoc, updateMask); + updateMask + ..clear() + ..addAll(cleaned); + } + final dbDoc = Document( fields: jsonDoc.map( (key, value) => MapEntry(key, _valueToDbValue(value)), @@ -1225,9 +1233,10 @@ class YustDatabaseService implements IYustDatabaseService { ); if (useUpdateMask) { write.updateMask = DocumentMask( - fieldPaths: doc.updateMask - .map((e) => YustHelpers().toQuotedFieldPath(e)!) - .toList(), + fieldPaths: cleanUpdateMask( + jsonDoc, + doc.updateMask, + ).map((e) => YustHelpers().toQuotedFieldPath(e)!).toList(), ); } final commitRequest = CommitRequest( diff --git a/lib/src/services/yust_database_service_flutter.dart b/lib/src/services/yust_database_service_flutter.dart index 5953f14c..52c916e5 100644 --- a/lib/src/services/yust_database_service_flutter.dart +++ b/lib/src/services/yust_database_service_flutter.dart @@ -450,6 +450,13 @@ class YustDatabaseService implements IYustDatabaseService { final jsonDoc = doc.toJson(); + if (updateMask != null) { + final cleaned = cleanUpdateMask(jsonDoc, updateMask); + updateMask + ..clear() + ..addAll(cleaned); + } + final modifiedDoc = _prepareJsonForFirebase( doNotCreate && updateMask != null ? _getValuesByUpdateMask(jsonDoc, updateMask) diff --git a/lib/src/services/yust_database_service_shared.dart b/lib/src/services/yust_database_service_shared.dart index 70966364..59cc7948 100644 --- a/lib/src/services/yust_database_service_shared.dart +++ b/lib/src/services/yust_database_service_shared.dart @@ -36,6 +36,44 @@ Future prepareSaveDoc( if (!skipOnSave) await docSetup.onSave?.call(doc); } +/// Cleans a Firestore update field [mask] so it never references a path that is +/// missing from [data]. +List cleanUpdateMask(Map data, Iterable mask) { + final resolved = {}; + for (final path in mask) { + final existing = _nearestExistingPath(data, path); + if (existing != null) resolved.add(existing); + } + + // Drop any path that has a strict ancestor also present in the mask. + return resolved + .where( + (path) => !resolved.any( + (other) => other != path && path.startsWith('$other.'), + ), + ) + .toList(); +} + +/// Returns the longest prefix of [path] that resolves to an existing key in +/// [data] (the resolved value may itself be `null`), or `null` if not even the +/// first segment exists. Backtick-quoting of segments is tolerated. +String? _nearestExistingPath(Map data, String path) { + final segments = path.split('.'); + final kept = []; + dynamic current = data; + for (final segment in segments) { + final key = segment.replaceAll('`', ''); + if (current is Map && current.containsKey(key)) { + kept.add(segment); + current = current[key]; + } else { + break; + } + } + return kept.isEmpty ? null : kept.join('.'); +} + T doInitDoc(YustDocSetup docSetup, String id, [T? doc]) { if (docSetup.newDoc == null) { throw YustException( From 946e6edeacb17da30dc2a9b09351810ac3ae271b Mon Sep 17 00:00:00 2001 From: Pierre Sucker Date: Fri, 24 Jul 2026 13:35:12 +0200 Subject: [PATCH 2/2] test: add unit tests for cleanUpdateMask --- .../yust_database_service_shared_test.dart | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 test/services/yust_database_service_shared_test.dart diff --git a/test/services/yust_database_service_shared_test.dart b/test/services/yust_database_service_shared_test.dart new file mode 100644 index 00000000..ac73044f --- /dev/null +++ b/test/services/yust_database_service_shared_test.dart @@ -0,0 +1,121 @@ +import 'package:test/test.dart'; +import 'package:yust/src/services/yust_database_service_shared.dart'; + +void main() { + group('cleanUpdateMask', () { + test( + 'drops stale nested paths when the field was cleared to null (map→null)', + () { + final data = { + 'brickValues': {'f3VOLnWX': null}, + }; + final mask = [ + 'brickValues.f3VOLnWX.selectedId', + 'brickValues.f3VOLnWX.labelMl.de', + 'brickValues.f3VOLnWX', + ]; + + expect( + cleanUpdateMask(data, mask), + unorderedEquals(['brickValues.f3VOLnWX']), + ); + }, + ); + + test('collapses a pure child path to the nearest existing ancestor', () { + final data = { + 'brickValues': {'f3VOLnWX': null}, + }; + final mask = ['brickValues.f3VOLnWX.selectedId']; + + expect( + cleanUpdateMask(data, mask), + unorderedEquals(['brickValues.f3VOLnWX']), + ); + }); + + test('collapses overlapping ancestor and descendant paths', () { + final data = { + 'a': { + 'b': {'c': 1}, + }, + }; + final mask = ['a', 'a.b.c']; + + expect(cleanUpdateMask(data, mask), unorderedEquals(['a'])); + }); + + test('keeps a valid nested map mask unchanged', () { + final data = { + 'a': { + 'b': {'c': 1, 'd': 2}, + }, + }; + final mask = ['a.b.c', 'a.b.d']; + + expect(cleanUpdateMask(data, mask), unorderedEquals(['a.b.c', 'a.b.d'])); + }); + + test('keeps scalar field paths unchanged', () { + final data = {'name': 'foo', 'count': 1, 'flag': null}; + final mask = ['name', 'count', 'flag']; + + expect( + cleanUpdateMask(data, mask), + unorderedEquals(['name', 'count', 'flag']), + ); + }); + + test('keeps a whole-field path when the data holds a map (null→map)', () { + final data = { + 'brickValues': { + 'f3VOLnWX': {'selectedId': 'status_2'}, + }, + }; + final mask = ['brickValues.f3VOLnWX']; + + expect( + cleanUpdateMask(data, mask), + unorderedEquals(['brickValues.f3VOLnWX']), + ); + }); + + test('drops a path whose first segment does not exist in the data', () { + final data = {'name': 'foo'}; + final mask = ['missing.bar', 'name']; + + expect(cleanUpdateMask(data, mask), unorderedEquals(['name'])); + }); + + test('tolerates backtick-quoted segments', () { + final data = { + 'brickValues': {'0abc': null}, + }; + final mask = ['brickValues.`0abc`.selectedId']; + + expect( + cleanUpdateMask(data, mask), + unorderedEquals(['brickValues.`0abc`']), + ); + }); + + test('deduplicates paths that resolve to the same existing ancestor', () { + final data = { + 'brickValues': {'f3VOLnWX': null}, + }; + final mask = [ + 'brickValues.f3VOLnWX.selectedId', + 'brickValues.f3VOLnWX.labelMl.de', + ]; + + expect( + cleanUpdateMask(data, mask), + unorderedEquals(['brickValues.f3VOLnWX']), + ); + }); + + test('returns an empty mask for an empty input mask', () { + expect(cleanUpdateMask({'a': 1}, []), isEmpty); + }); + }); +}