diff --git a/packages/dart_firebase_admin/lib/src/utils/utils.dart b/packages/dart_firebase_admin/lib/src/utils/utils.dart index fa9444ea..9ecf51a6 100644 --- a/packages/dart_firebase_admin/lib/src/utils/utils.dart +++ b/packages/dart_firebase_admin/lib/src/utils/utils.dart @@ -20,35 +20,38 @@ String get dartVersion => /// Generates the update mask for the provided object. /// Note this will ignore the last key with value `null`. -List generateUpdateMask( - Object? obj, { - List terminalPaths = const [], - String root = '', -}) { +List _generateUpdateMask(Object? obj, String root) { if (obj is! Map) return []; final updateMask = []; for (final key in obj.keys) { - final nextPath = root.isEmpty ? '$root.$key' : '$key'; + final nextPath = root.isEmpty ? key.toString() : '$root.$key'; // We hit maximum path. // Consider switching to Set if the list grows too large. - if (terminalPaths.contains(nextPath)) { - // Add key and stop traversing this branch. - updateMask.add('$key'); - } else { - final maskList = generateUpdateMask( - obj[key], - terminalPaths: terminalPaths, - root: nextPath, - ); - if (maskList.isNotEmpty) { - for (final mask in maskList) { - updateMask.add('$key.$mask'); - } - } else { - updateMask.add('$key'); + final maskList = _generateUpdateMask(obj[key], nextPath); + if (maskList.isNotEmpty) { + for (final mask in maskList) { + updateMask.add('$key.$mask'); } + } else { + updateMask.add('$key'); } } return updateMask; } + +/// Generates a list of field paths (update mask) for the provided object. +/// +/// Returns an empty list if the [obj] is not a [Map]. +/// +/// All keys present in the map are included in the mask. If a key's value is +/// another map, the paths are extended to the keys of that map. If a key's +/// value is not a map (including primitive values like numbers, strings, and +/// `null`), that key becomes a leaf in the path. +/// +/// Example: +/// ```dart +/// generateUpdateMask({'a': 1, 'b': {'c': null, 'd': {}}}) +/// // Returns: ['a', 'b.c', 'b.d'] +/// ``` +List generateUpdateMask(Object? obj) => _generateUpdateMask(obj, ''); diff --git a/packages/dart_firebase_admin/test/unit/utils_test.dart b/packages/dart_firebase_admin/test/unit/utils_test.dart new file mode 100644 index 00000000..ba379dbb --- /dev/null +++ b/packages/dart_firebase_admin/test/unit/utils_test.dart @@ -0,0 +1,62 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:dart_firebase_admin/src/utils/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('generateUpdateMask', () { + test('non-map objects', () { + expect(generateUpdateMask(null), isEmpty); + expect(generateUpdateMask(1), isEmpty); + expect(generateUpdateMask('string'), isEmpty); + expect(generateUpdateMask([]), isEmpty); + }); + + test('empty map', () { + final obj = {}; + expect(generateUpdateMask(obj), isEmpty); + }); + + test('flat map', () { + final obj = {'a': 1, 'b': 'string', 'c': null}; + expect(generateUpdateMask(obj), containsAll(['a', 'b', 'c'])); + }); + + test('nested maps', () { + final obj = { + 'a': 1, + 'b': {'c': 2, 'd': null}, + }; + expect(generateUpdateMask(obj), containsAll(['a', 'b.c', 'b.d'])); + }); + + test('deeply nested maps', () { + final obj = { + 'a': { + 'b': {'c': null}, + }, + }; + expect(generateUpdateMask(obj), containsAll(['a.b.c'])); + }); + + test('empty maps as leaf nodes', () { + final obj = { + 'a': {}, + 'b': {'c': {}}, + }; + expect(generateUpdateMask(obj), containsAll(['a', 'b.c'])); + }); + }); +}