From b2ee8317df65859a25eaa809ba9607f7d3b06b4c Mon Sep 17 00:00:00 2001 From: David Morgan Date: Sat, 12 Apr 2025 12:58:47 +0200 Subject: [PATCH] Add mutable collection serializers: List, Map, Set. --- CHANGELOG.md | 4 + built_value/lib/serializer.dart | 6 + built_value/lib/src/list_serializer.dart | 49 ++++ built_value/lib/src/map_serializer.dart | 70 +++++ built_value/lib/src/set_serializer.dart | 49 ++++ built_value/test/list_serializer_test.dart | 109 ++++++++ built_value/test/map_serializer_test.dart | 283 +++++++++++++++++++++ built_value/test/set_serializer_test.dart | 113 ++++++++ 8 files changed, 683 insertions(+) create mode 100644 built_value/lib/src/list_serializer.dart create mode 100644 built_value/lib/src/map_serializer.dart create mode 100644 built_value/lib/src/set_serializer.dart create mode 100644 built_value/test/list_serializer_test.dart create mode 100644 built_value/test/map_serializer_test.dart create mode 100644 built_value/test/set_serializer_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dc47eef5..85a50d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ # 8.10.0 +- Add mutable collection serializers: `List`, `Set`, `Map`. These let you + easily serialize mutable collections of immutable value types; it's still + recommended to avoid mutable collections inside value types, as they break + hashing, comparison and caching. - Stop generating unnecessary `new` keywords. - Stop generating explicit null checks in constructors: these are not needed with sound null safety. diff --git a/built_value/lib/serializer.dart b/built_value/lib/serializer.dart index 41e56ae1..ff006f3c 100644 --- a/built_value/lib/serializer.dart +++ b/built_value/lib/serializer.dart @@ -10,7 +10,10 @@ import 'package:built_value/src/duration_serializer.dart'; import 'package:built_value/src/int32_serializer.dart'; import 'package:built_value/src/int64_serializer.dart'; import 'package:built_value/src/json_object_serializer.dart'; +import 'package:built_value/src/list_serializer.dart'; +import 'package:built_value/src/map_serializer.dart'; import 'package:built_value/src/num_serializer.dart'; +import 'package:built_value/src/set_serializer.dart'; import 'package:built_value/src/uint8_list_serializer.dart'; import 'package:built_value/src/uri_serializer.dart'; @@ -59,6 +62,9 @@ abstract class Serializers { return (SerializersBuilder() ..add(BigIntSerializer()) ..add(BoolSerializer()) + ..add(ListSerializer()) + ..add(MapSerializer()) + ..add(SetSerializer()) ..add(BuiltListSerializer()) ..add(BuiltListMultimapSerializer()) ..add(BuiltMapSerializer()) diff --git a/built_value/lib/src/list_serializer.dart b/built_value/lib/src/list_serializer.dart new file mode 100644 index 00000000..d0a82396 --- /dev/null +++ b/built_value/lib/src/list_serializer.dart @@ -0,0 +1,49 @@ +// Copyright (c) 2025, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/serializer.dart'; + +class ListSerializer implements StructuredSerializer { + final bool structured = true; + @override + final Iterable types = BuiltList([List, [].runtimeType]); + @override + final String wireName = 'List'; + + @override + Iterable serialize(Serializers serializers, List list, + {FullType specifiedType = FullType.unspecified}) { + var isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + if (!isUnderspecified) serializers.expectBuilder(specifiedType); + + var elementType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[0]; + + return list + .map((item) => serializers.serialize(item, specifiedType: elementType)); + } + + @override + List deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + var isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + + var elementType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[0]; + + var result = isUnderspecified + ? [] + : serializers.newBuilder(specifiedType) as List; + + for (final item in serialized) { + result.add(serializers.deserialize(item, specifiedType: elementType)); + } + return result; + } +} diff --git a/built_value/lib/src/map_serializer.dart b/built_value/lib/src/map_serializer.dart new file mode 100644 index 00000000..c77621ac --- /dev/null +++ b/built_value/lib/src/map_serializer.dart @@ -0,0 +1,70 @@ +// Copyright (c) 2025, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/serializer.dart'; + +class MapSerializer implements StructuredSerializer { + final bool structured = true; + @override + final Iterable types = + BuiltList([Map, {}.runtimeType]); + @override + final String wireName = 'Map'; + + @override + Iterable serialize(Serializers serializers, Map Map, + {FullType specifiedType = FullType.unspecified}) { + var isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + if (!isUnderspecified) serializers.expectBuilder(specifiedType); + + var keyType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[0]; + var valueType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[1]; + + var result = []; + for (var key in Map.keys) { + result.add(serializers.serialize(key, specifiedType: keyType)); + final value = Map[key]; + result.add(serializers.serialize(value, specifiedType: valueType)); + } + return result; + } + + @override + Map deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + var isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + + var keyType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[0]; + var valueType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[1]; + + var result = isUnderspecified + ? {} + : serializers.newBuilder(specifiedType) as Map; + + if (serialized.length % 2 == 1) { + throw ArgumentError('odd length'); + } + + for (var i = 0; i != serialized.length; i += 2) { + final key = serializers.deserialize(serialized.elementAt(i), + specifiedType: keyType); + final value = serializers.deserialize(serialized.elementAt(i + 1), + specifiedType: valueType); + result[key] = value; + } + + return result; + } +} diff --git a/built_value/lib/src/set_serializer.dart b/built_value/lib/src/set_serializer.dart new file mode 100644 index 00000000..ca53bb88 --- /dev/null +++ b/built_value/lib/src/set_serializer.dart @@ -0,0 +1,49 @@ +// Copyright (c) 2025, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/serializer.dart'; + +class SetSerializer implements StructuredSerializer { + final bool structured = true; + @override + final Iterable types = BuiltSet([Set, {}.runtimeType]); + @override + final String wireName = 'Set'; + + @override + Iterable serialize(Serializers serializers, Set set, + {FullType specifiedType = FullType.unspecified}) { + var isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + if (!isUnderspecified) serializers.expectBuilder(specifiedType); + + var elementType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[0]; + + return set + .map((item) => serializers.serialize(item, specifiedType: elementType)); + } + + @override + Set deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + var isUnderspecified = + specifiedType.isUnspecified || specifiedType.parameters.isEmpty; + + var elementType = specifiedType.parameters.isEmpty + ? FullType.unspecified + : specifiedType.parameters[0]; + + var result = isUnderspecified + ? {} + : serializers.newBuilder(specifiedType) as Set; + + for (final item in serialized) { + result.add(serializers.deserialize(item, specifiedType: elementType)); + } + return result; + } +} diff --git a/built_value/test/list_serializer_test.dart b/built_value/test/list_serializer_test.dart new file mode 100644 index 00000000..d33e6620 --- /dev/null +++ b/built_value/test/list_serializer_test.dart @@ -0,0 +1,109 @@ +// Copyright (c) 2025, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:built_value/serializer.dart'; +import 'package:test/test.dart'; + +void main() { + group('List with known specifiedType but missing builder', () { + var data = [1, 2, 3]; + var specifiedType = const FullType(List, [FullType(int)]); + var serializers = Serializers(); + var serialized = json.decode(json.encode([1, 2, 3])) as Object; + + test('serialize throws', () { + expect(() => serializers.serialize(data, specifiedType: specifiedType), + throwsA(const TypeMatcher())); + }); + + test('deserialize throws', () { + expect( + () => + serializers.deserialize(serialized, specifiedType: specifiedType), + throwsA(const TypeMatcher())); + }); + }); + + group('List with known specifiedType and correct builder', () { + var data = [1, 2, 3]; + var specifiedType = const FullType(List, [FullType(int)]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(specifiedType, () => [])) + .build(); + var serialized = json.decode(json.encode([1, 2, 3])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + + test('keeps generic type when deserialized', () { + expect( + serializers + .deserialize(serialized, specifiedType: specifiedType) + .runtimeType, + [].runtimeType); + }); + }); + + group('List nested with known specifiedType and correct builders', () { + var data = >[ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ]; + var specifiedType = const FullType(List, [ + FullType(List, [FullType(int)]) + ]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(specifiedType, () => >[]) + ..addBuilderFactory( + const FullType(List, [FullType(int)]), () => [])) + .build(); + var serialized = json.decode(json.encode([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); + + group('List with unknown specifiedType and no builders', () { + var data = [1, 2, 3]; + var specifiedType = FullType.unspecified; + var serializers = Serializers(); + var serialized = json.decode(json.encode([ + 'List', + ['int', 1], + ['int', 2], + ['int', 3] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); +} diff --git a/built_value/test/map_serializer_test.dart b/built_value/test/map_serializer_test.dart new file mode 100644 index 00000000..cc0ef11a --- /dev/null +++ b/built_value/test/map_serializer_test.dart @@ -0,0 +1,283 @@ +// Copyright (c) 2025, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:built_value/serializer.dart'; +import 'package:test/test.dart'; + +void main() { + group('Map with known specifiedType but missing builder', () { + var data = {1: 'one', 2: 'two', 3: 'three'}; + var specifiedType = const FullType(Map, [FullType(int), FullType(String)]); + var serializers = Serializers(); + var serialized = + json.decode(json.encode([1, 'one', 2, 'two', 3, 'three'])) as Object; + + test('cannot be serialized', () { + expect(() => serializers.serialize(data, specifiedType: specifiedType), + throwsA(const TypeMatcher())); + }); + + test('cannot be deserialized', () { + expect( + () => + serializers.deserialize(serialized, specifiedType: specifiedType), + throwsA(const TypeMatcher())); + }); + }); + + group('Map with known specifiedType and correct builder', () { + var data = {1: 'one', 2: 'two', 3: 'three'}; + var specifiedType = const FullType(Map, [FullType(int), FullType(String)]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(specifiedType, () => {})) + .build(); + var serialized = + json.decode(json.encode([1, 'one', 2, 'two', 3, 'three'])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + + test('keeps generic type when deserialized', () { + expect( + serializers + .deserialize(serialized, specifiedType: specifiedType) + .runtimeType, + {}.runtimeType); + }); + }); + + group('Map nested left with known specifiedType', () { + var data = , String>{ + {1: 'one'}: 'one!', + {2: 'two'}: 'two!' + }; + const innerTypeLeft = FullType(Map, [FullType(int), FullType(String)]); + var specifiedType = const FullType(Map, [innerTypeLeft, FullType(String)]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(innerTypeLeft, () => {}) + ..addBuilderFactory( + specifiedType, () => , String>{})) + .build(); + var serialized = json.decode(json.encode([ + [1, 'one'], + 'one!', + [2, 'two'], + 'two!' + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + // `expect` does not deep compare `Map` by key, `toString` is close + // enough. + expect( + serializers + .deserialize(serialized, specifiedType: specifiedType) + .toString(), + data.toString()); + }); + }); + + group('Map nested right with known specifiedType', () { + var data = >{ + 1: {'one': 'one!'}, + 2: {'two': 'two!'} + }; + const innerTypeRight = FullType(Map, [FullType(String), FullType(String)]); + var specifiedType = const FullType(Map, [FullType(int), innerTypeRight]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(innerTypeRight, () => {}) + ..addBuilderFactory( + specifiedType, () => >{})) + .build(); + var serialized = json.decode(json.encode([ + 1, + ['one', 'one!'], + 2, + ['two', 'two!'] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); + + group('Map nested both with known specifiedType', () { + var data = , Map>{ + {1: 1}: {'one': 'one!'}, + {2: 2}: {'two': 'two!'} + }; + const MapOfIntIntGenericType = + FullType(Map, [FullType(int), FullType(int)]); + const MapOfStringStringGenericType = + FullType(Map, [FullType(String), FullType(String)]); + var specifiedType = const FullType( + Map, [MapOfIntIntGenericType, MapOfStringStringGenericType]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(MapOfIntIntGenericType, () => {}) + ..addBuilderFactory( + MapOfStringStringGenericType, () => {}) + ..addBuilderFactory( + specifiedType, () => , Map>{})) + .build(); + var serialized = json.decode(json.encode([ + [1, 1], + ['one', 'one!'], + [2, 2], + ['two', 'two!'] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + // `expect` does not deep compare `Map` by key, `toString` is close + // enough. + expect( + serializers + .deserialize(serialized, specifiedType: specifiedType) + .toString(), + data.toString()); + }); + + test('keeps generic type on deserialization', () { + final genericSerializer = (serializers.toBuilder() + ..addBuilderFactory( + specifiedType, () => , Map>{}) + ..addBuilderFactory(MapOfIntIntGenericType, () => {}) + ..addBuilderFactory( + MapOfStringStringGenericType, () => {})) + .build(); + + expect( + genericSerializer + .deserialize(serialized, specifiedType: specifiedType) + .runtimeType, + , Map>{}.runtimeType); + }); + }); + + group('Map with Object values', () { + var data = {1: 'one', 2: 2, 3: 'three'}; + var specifiedType = + const FullType(Map, [FullType(int), FullType.unspecified]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(specifiedType, () => {})) + .build(); + var serialized = json.decode(json.encode([ + 1, + ['String', 'one'], + 2, + ['int', 2], + 3, + ['String', 'three'] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); + + group('Map with Object keys', () { + var data = {1: 'one', 'two': 'two', 3: 'three'}; + var specifiedType = + const FullType(Map, [FullType.unspecified, FullType(String)]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(specifiedType, () => {})) + .build(); + var serialized = json.decode(json.encode([ + ['int', 1], + 'one', + ['String', 'two'], + 'two', + ['int', 3], + 'three' + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); + + group('Map with Object keys and values', () { + var data = {1: 'one', 'two': 2, 3: 'three'}; + var specifiedType = const FullType(Map); + var serializers = Serializers(); + var serialized = json.decode(json.encode([ + ['int', 1], + ['String', 'one'], + ['String', 'two'], + ['int', 2], + ['int', 3], + ['String', 'three'] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); + + group('Map with unknown specifiedType', () { + var data = {1: 'one', 'two': 2, 3: 'three'}; + var specifiedType = FullType.unspecified; + var serializers = Serializers(); + var serialized = json.decode(json.encode([ + 'Map', + ['int', 1], + ['String', 'one'], + ['String', 'two'], + ['int', 2], + ['int', 3], + ['String', 'three'] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); +} diff --git a/built_value/test/set_serializer_test.dart b/built_value/test/set_serializer_test.dart new file mode 100644 index 00000000..5c862889 --- /dev/null +++ b/built_value/test/set_serializer_test.dart @@ -0,0 +1,113 @@ +// Copyright (c) 2025, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:built_value/serializer.dart'; +import 'package:test/test.dart'; + +// Note: Set preserves order, so comparisons in these tests can assume a +// specific ordering. In fact, these tests are exactly the ListSerializer +// tests with "list" replaced by "set". + +void main() { + group('Set with known specifiedType but missing builder', () { + var data = {1, 2, 3}; + var specifiedType = const FullType(Set, [FullType(int)]); + var serializers = Serializers(); + var serialized = json.decode(json.encode([1, 2, 3])) as Object; + + test('serialize throws', () { + expect(() => serializers.serialize(data, specifiedType: specifiedType), + throwsA(const TypeMatcher())); + }); + + test('deserialize throws', () { + expect( + () => + serializers.deserialize(serialized, specifiedType: specifiedType), + throwsA(const TypeMatcher())); + }); + }); + + group('Set with known specifiedType and correct builder', () { + var data = {1, 2, 3}; + var specifiedType = const FullType(Set, [FullType(int)]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(specifiedType, () => {})) + .build(); + var serialized = json.decode(json.encode([1, 2, 3])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + + test('keeps generic type when deserialized', () { + expect( + serializers + .deserialize(serialized, specifiedType: specifiedType) + .runtimeType, + {}.runtimeType); + }); + }); + + group('Set nested with known specifiedType and correct builders', () { + var data = >{ + {1, 2, 3}, + {4, 5, 6}, + {7, 8, 9} + }; + var specifiedType = const FullType(Set, [ + FullType(Set, [FullType(int)]) + ]); + var serializers = (Serializers().toBuilder() + ..addBuilderFactory(specifiedType, () => >{}) + ..addBuilderFactory( + const FullType(Set, [FullType(int)]), () => {})) + .build(); + var serialized = json.decode(json.encode([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); + + group('Set with unknown specifiedType and no builders', () { + var data = {1, 2, 3}; + var specifiedType = FullType.unspecified; + var serializers = Serializers(); + var serialized = json.decode(json.encode([ + 'Set', + ['int', 1], + ['int', 2], + ['int', 3] + ])) as Object; + + test('can be serialized', () { + expect(serializers.serialize(data, specifiedType: specifiedType), + serialized); + }); + + test('can be deserialized', () { + expect(serializers.deserialize(serialized, specifiedType: specifiedType), + data); + }); + }); +}