diff --git a/json_serializable/CHANGELOG.md b/json_serializable/CHANGELOG.md index 7e6aa60e6..0539b0b93 100644 --- a/json_serializable/CHANGELOG.md +++ b/json_serializable/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.6.0 +- Allow custom map types to automatically have key types serialized. + Fixes[#396](https://github.com/google/json_serializable.dart/issues/396) + ## 6.5.3 - Fixed handling of nullable `enum` fields with `includeIfNull: false`. diff --git a/json_serializable/lib/src/type_helpers/json_helper.dart b/json_serializable/lib/src/type_helpers/json_helper.dart index 426c77d14..06f4c05f3 100644 --- a/json_serializable/lib/src/type_helpers/json_helper.dart +++ b/json_serializable/lib/src/type_helpers/json_helper.dart @@ -12,9 +12,11 @@ import 'package:source_helper/source_helper.dart'; import '../default_container.dart'; import '../type_helper.dart'; +import '../unsupported_type_error.dart'; import '../utils.dart'; import 'config_types.dart'; import 'generic_factory_helper.dart'; +import 'map_helper.dart'; const _helperLambdaParam = 'value'; @@ -49,11 +51,12 @@ class JsonHelper extends TypeHelper { toJsonArgs.addAll( _helperParams( - context.serialize, + context, _encodeHelper, interfaceType, toJson.parameters.where((element) => element.isRequiredPositional), toJson, + isSerializing: true, ), ); } @@ -109,11 +112,12 @@ class JsonHelper extends TypeHelper { final args = [ output, ..._helperParams( - context.deserialize, + context, _decodeHelper, targetType, positionalParams.skip(1), fromJsonCtor, + isSerializing: false, ), ]; @@ -137,13 +141,15 @@ class JsonHelper extends TypeHelper { } List _helperParams( - Object? Function(DartType, String) execute, - TypeParameterType Function(ParameterElement, Element) paramMapper, + TypeHelperContextWithConfig context, + TypeParameterTypeWithKeyHelper Function(ParameterElement, Element) + paramMapper, InterfaceType type, Iterable positionalParams, - Element targetElement, -) { - final rest = []; + Element targetElement, { + required bool isSerializing, +}) { + final rest = []; for (var param in positionalParams) { rest.add(paramMapper(param, targetElement)); } @@ -152,18 +158,28 @@ List _helperParams( for (var helperArg in rest) { final typeParamIndex = - type.element2.typeParameters.indexOf(helperArg.element2); + type.element2.typeParameters.indexOf(helperArg.type.element2); // TODO: throw here if `typeParamIndex` is -1 ? final typeArg = type.typeArguments[typeParamIndex]; - final body = execute(typeArg, _helperLambdaParam); - args.add('($_helperLambdaParam) => $body'); + final body = isSerializing + ? context.serialize(typeArg, _helperLambdaParam) + : context.deserialize(typeArg, _helperLambdaParam); + if (helperArg.isJsonKey) { + const keyHelper = MapKeyHelper(); + final newBody = isSerializing + ? keyHelper.serialize(typeArg, '', context) + : keyHelper.deserialize(typeArg, '', context, false); + args.add('($_helperLambdaParam) => $newBody'); + } else { + args.add('($_helperLambdaParam) => $body'); + } } return args; } -TypeParameterType _decodeHelper( +TypeParameterTypeWithKeyHelper _decodeHelper( ParameterElement param, Element targetElement, ) { @@ -178,8 +194,11 @@ TypeParameterType _decodeHelper( final funcParamType = type.normalParameterTypes.single; if ((funcParamType.isDartCoreObject && funcParamType.isNullableType) || - funcParamType.isDynamic) { - return funcReturnType as TypeParameterType; + funcParamType.isDynamic || + funcParamType.isDartCoreString) { + return TypeParameterTypeWithKeyHelper( + funcReturnType as TypeParameterType, + funcParamType.isDartCoreString); } } } @@ -194,20 +213,30 @@ TypeParameterType _decodeHelper( ); } -TypeParameterType _encodeHelper( +class TypeParameterTypeWithKeyHelper { + final TypeParameterType type; + final bool isJsonKey; + + TypeParameterTypeWithKeyHelper(this.type, this.isJsonKey); +} + +TypeParameterTypeWithKeyHelper _encodeHelper( ParameterElement param, Element targetElement, ) { final type = param.type; if (type is FunctionType && - (type.returnType.isDartCoreObject || type.returnType.isDynamic) && + (type.returnType.isDartCoreObject || + type.returnType.isDynamic || + type.returnType.isDartCoreString) && type.normalParameterTypes.length == 1) { final funcParamType = type.normalParameterTypes.single; if (param.name == toJsonForName(funcParamType.element2!.name!)) { if (funcParamType is TypeParameterType) { - return funcParamType; + return TypeParameterTypeWithKeyHelper( + funcParamType, type.returnType.isDartCoreString); } } } @@ -290,3 +319,71 @@ ClassConfig? _annotation(ClassConfig config, InterfaceType source) { MethodElement? _toJsonMethod(DartType type) => type.typeImplementations .map((dt) => dt is InterfaceType ? dt.getMethod('toJson') : null) .firstWhereOrNull((me) => me != null); + +class MapKeyHelper extends TypeHelper { + const MapKeyHelper(); + + @override + String? serialize( + DartType targetType, + String expression, + TypeHelperContextWithConfig context, + ) { + final keyType = targetType; + + checkSafeMapKeyType(expression, keyType); + + final subKeyValue = mapKeyHelperForType(keyType) + ?.serialize(keyType, _helperLambdaParam, false) ?? + context.serialize(keyType, _helperLambdaParam); + + if (_helperLambdaParam == subKeyValue) { + return expression; + } + + return '$subKeyValue'; + } + + @override + String? deserialize( + DartType targetType, + String expression, + TypeHelperContextWithConfig context, + bool defaultProvided, + ) { + final keyArg = targetType; + + checkSafeMapKeyType(expression, keyArg); + + final isKeyStringable = isMapKeyStringable(keyArg); + if (!isKeyStringable) { + throw UnsupportedTypeError( + keyArg, + expression, + 'Map keys must be one of: ${allowedMapKeyTypes.join(', ')}.', + ); + } + + String keyUsage; + if (keyArg.isEnum) { + keyUsage = context.deserialize(keyArg, _helperLambdaParam).toString(); + } else if (context.config.anyMap && + !(keyArg.isDartCoreObject || keyArg.isDynamic)) { + keyUsage = '$_helperLambdaParam as String'; + } else if (context.config.anyMap && + keyArg.isDartCoreObject && + !keyArg.isNullableType) { + keyUsage = '$_helperLambdaParam as Object'; + } else { + keyUsage = '$_helperLambdaParam as String'; + } + + final toFromString = mapKeyHelperForType(keyArg); + if (toFromString != null) { + keyUsage = + toFromString.deserialize(keyArg, keyUsage, false, true)!.toString(); + } + + return keyUsage; + } +} diff --git a/json_serializable/lib/src/type_helpers/map_helper.dart b/json_serializable/lib/src/type_helpers/map_helper.dart index e84bc5193..398fd4e41 100644 --- a/json_serializable/lib/src/type_helpers/map_helper.dart +++ b/json_serializable/lib/src/type_helpers/map_helper.dart @@ -32,11 +32,11 @@ class MapHelper extends TypeHelper { final keyType = args[0]; final valueType = args[1]; - _checkSafeKeyType(expression, keyType); + checkSafeMapKeyType(expression, keyType); final subFieldValue = context.serialize(valueType, closureArg); final subKeyValue = - _forType(keyType)?.serialize(keyType, _keyParam, false) ?? + mapKeyHelperForType(keyType)?.serialize(keyType, _keyParam, false) ?? context.serialize(keyType, _keyParam); if (closureArg == subFieldValue && _keyParam == subKeyValue) { @@ -66,11 +66,11 @@ class MapHelper extends TypeHelper { final keyArg = typeArgs.first; final valueArg = typeArgs.last; - _checkSafeKeyType(expression, keyArg); + checkSafeMapKeyType(expression, keyArg); final valueArgIsAny = valueArg.isDynamic || (valueArg.isDartCoreObject && valueArg.isNullableType); - final isKeyStringable = _isKeyStringable(keyArg); + final isKeyStringable = isMapKeyStringable(keyArg); final targetTypeIsNullable = defaultProvided || targetType.isNullableType; final optionalQuestion = targetTypeIsNullable ? '?' : ''; @@ -124,7 +124,7 @@ class MapHelper extends TypeHelper { keyUsage = _keyParam; } - final toFromString = _forType(keyArg); + final toFromString = mapKeyHelperForType(keyArg); if (toFromString != null) { keyUsage = toFromString.deserialize(keyArg, keyUsage, false, true).toString(); @@ -146,22 +146,22 @@ final _instances = [ uriString, ]; -ToFromStringHelper? _forType(DartType type) => +ToFromStringHelper? mapKeyHelperForType(DartType type) => _instances.singleWhereOrNull((i) => i.matches(type)); /// Returns `true` if [keyType] can be automatically converted to/from String – /// and is therefor usable as a key in a [Map]. -bool _isKeyStringable(DartType keyType) => +bool isMapKeyStringable(DartType keyType) => keyType.isEnum || _instances.any((inst) => inst.matches(keyType)); -void _checkSafeKeyType(String expression, DartType keyArg) { +void checkSafeMapKeyType(String expression, DartType keyArg) { // We're not going to handle converting key types at the moment // So the only safe types for key are dynamic/Object/String/enum if (keyArg.isDynamic || (!keyArg.isNullableType && (keyArg.isDartCoreObject || coreStringTypeChecker.isExactlyType(keyArg) || - _isKeyStringable(keyArg)))) { + isMapKeyStringable(keyArg)))) { return; } @@ -174,7 +174,7 @@ void _checkSafeKeyType(String expression, DartType keyArg) { /// The names of types that can be used as [Map] keys. /// -/// Used in [_checkSafeKeyType] to provide a helpful error with unsupported +/// Used in [checkSafeMapKeyType] to provide a helpful error with unsupported /// types. List get allowedMapKeyTypes => [ 'Object', diff --git a/json_serializable/test/generic_files/generic_argument_factories.dart b/json_serializable/test/generic_files/generic_argument_factories.dart index 0ebb09d69..e8ed84d80 100644 --- a/json_serializable/test/generic_files/generic_argument_factories.dart +++ b/json_serializable/test/generic_files/generic_argument_factories.dart @@ -50,3 +50,33 @@ class ConcreteClass { Map toJson() => _$ConcreteClassToJson(this); } + +class CustomMap { + final Map map; + + CustomMap(this.map); + + factory CustomMap.fromJson( + Map json, + K Function(String?) fromJsonK, + V Function(Object?) fromJsonV, + ) => + CustomMap(json.map( + (key, value) => MapEntry(fromJsonK(key), fromJsonV(value)))); + + Map toJson( + String? Function(K) toJsonK, Object? Function(V) toJsonV) => + map.map((key, value) => MapEntry(toJsonK(key), toJsonV(value))); +} + +@JsonSerializable() +class UseOfCustomMap { + final CustomMap map; + + UseOfCustomMap(this.map); + + factory UseOfCustomMap.fromJson(Map json) => + _$UseOfCustomMapFromJson(json); + + Map toJson() => _$UseOfCustomMapToJson(this); +} diff --git a/json_serializable/test/generic_files/generic_argument_factories.g.dart b/json_serializable/test/generic_files/generic_argument_factories.g.dart index 87755ac37..8d1ff2622 100644 --- a/json_serializable/test/generic_files/generic_argument_factories.g.dart +++ b/json_serializable/test/generic_files/generic_argument_factories.g.dart @@ -61,3 +61,17 @@ Map _$ConcreteClassToJson(ConcreteClass instance) => (value) => value?.toString(), ), }; + +UseOfCustomMap _$UseOfCustomMapFromJson(Map json) => + UseOfCustomMap( + CustomMap.fromJson(json['map'] as Map, + (value) => int.parse(value as String), (value) => value as String), + ); + +Map _$UseOfCustomMapToJson(UseOfCustomMap instance) => + { + 'map': instance.map.toJson( + (value) => value.toString(), + (value) => value, + ), + };