Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions lib/src/services/yust_database_service_dart.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions lib/src/services/yust_database_service_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions lib/src/services/yust_database_service_shared.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ Future<void> prepareSaveDoc<T extends YustDoc>(
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<String> cleanUpdateMask(Map<String, dynamic> data, Iterable<String> mask) {
final resolved = <String>{};
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<String, dynamic> data, String path) {
final segments = path.split('.');
final kept = <String>[];
dynamic current = data;
for (final segment in segments) {
final key = segment.replaceAll('`', '');
if (current is Map<String, dynamic> && current.containsKey(key)) {
kept.add(segment);
current = current[key];
} else {
break;
}
}
return kept.isEmpty ? null : kept.join('.');
}

T doInitDoc<T extends YustDoc>(YustDocSetup<T> docSetup, String id, [T? doc]) {
if (docSetup.newDoc == null) {
throw YustException(
Expand Down
121 changes: 121 additions & 0 deletions test/services/yust_database_service_shared_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}