diff --git a/src/libraries/System.Text.Json/ref/System.Text.Json.cs b/src/libraries/System.Text.Json/ref/System.Text.Json.cs index 5a1a1e9d1ace6..d1394f939f1a1 100644 --- a/src/libraries/System.Text.Json/ref/System.Text.Json.cs +++ b/src/libraries/System.Text.Json/ref/System.Text.Json.cs @@ -1164,6 +1164,12 @@ public JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy? namingPolicy = public sealed override bool CanConvert(System.Type typeToConvert) { throw null; } public sealed override System.Text.Json.Serialization.JsonConverter CreateConverter(System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) { throw null; } } + [System.AttributeUsageAttribute(System.AttributeTargets.Field, AllowMultiple=false)] + public partial class JsonStringEnumMemberNameAttribute : System.Attribute + { + public JsonStringEnumMemberNameAttribute(string name) { } + public string Name { get { throw null; } } + } public enum JsonUnknownDerivedTypeHandling { FailSerialization = 0, diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index 63b26b0fb9bbd..e5dda53afde83 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -231,8 +231,8 @@ '{0}' is invalid within a JSON string. The string should be correctly escaped. - - Enum type '{0}' uses unsupported identifer name '{1}'. + + Enum type '{0}' uses unsupported identifier '{1}'. It must not be null, empty, or containing leading or trailing whitespace. Flags enums must additionally not contain commas. '{0}' is an invalid token type for the end of the JSON payload. Expected either 'EndArray' or 'EndObject'. diff --git a/src/libraries/System.Text.Json/src/System.Text.Json.csproj b/src/libraries/System.Text.Json/src/System.Text.Json.csproj index 736c6be2fdbfa..071f91c5aafee 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -266,6 +266,7 @@ The System.Text.Json library is built-in as part of the shared framework in .NET + diff --git a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs index 4767aa16155f8..f2e9945b49c47 100644 --- a/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs +++ b/src/libraries/System.Text.Json/src/System/ReflectionExtensions.cs @@ -95,11 +95,11 @@ private static bool HasCustomAttributeWithName(this MemberInfo memberInfo, strin /// Polyfill for BindingFlags.DoNotWrapExceptions /// public static object? CreateInstanceNoWrapExceptions( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.NonPublicConstructors)] this Type type, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] this Type type, Type[] parameterTypes, object?[] parameters) { - ConstructorInfo ctorInfo = type.GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, parameterTypes, null)!; + ConstructorInfo ctorInfo = type.GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, parameterTypes, null)!; #if NET return ctorInfo.Invoke(BindingFlags.DoNotWrapExceptions, null, parameters, null); #else diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs index 8d6f19f1a37ab..d6ade630a3986 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs @@ -317,7 +317,7 @@ public static bool HasAllSet(this BitArray bitArray) /// Gets a Regex instance for recognizing integer representations of enums. /// public static readonly Regex IntegerRegex = CreateIntegerRegex(); - private const string IntegerRegexPattern = @"^\s*(\+|\-)?[0-9]+\s*$"; + private const string IntegerRegexPattern = @"^\s*(?:\+|\-)?[0-9]+\s*$"; private const int IntegerRegexTimeoutMs = 200; #if NET diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs index 6ec71f05ddb38..e5006d23b84d4 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs @@ -3,8 +3,9 @@ using System.Buffers; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Encodings.Web; using System.Text.Json.Nodes; @@ -21,169 +22,106 @@ internal sealed class EnumConverter : JsonPrimitiveConverter private static readonly bool s_isSignedEnum = ((int)s_enumTypeCode % 2) == 1; private static readonly bool s_isFlagsEnum = typeof(T).IsDefined(typeof(FlagsAttribute), inherit: false); - private const string ValueSeparator = ", "; - private readonly EnumConverterOptions _converterOptions; private readonly JsonNamingPolicy? _namingPolicy; /// - /// Holds a mapping from enum value to text that might be formatted with . + /// Stores metadata for the individual fields declared on the enum. + /// + private readonly EnumFieldInfo[] _enumFieldInfo; + + /// + /// Defines a case-insensitive index of enum field names to their metadata. + /// In case of casing conflicts, extra fields are appended to a list in the value. + /// This is the main dictionary that is queried by the enum parser implementation. + /// + private readonly Dictionary _enumFieldInfoIndex; + + /// + /// Holds a cache from enum value to formatted UTF-8 text including flag combinations. /// is as the key used rather than given measurements that /// show private memory savings when a single type is used https://github.com/dotnet/runtime/pull/36726#discussion_r428868336. /// private readonly ConcurrentDictionary _nameCacheForWriting; /// - /// Holds a mapping from text that might be formatted with to enum value. + /// Holds a mapping from input text to enum values including flag combinations and alternative casings. /// - private readonly ConcurrentDictionary? _nameCacheForReading; + private readonly ConcurrentDictionary _nameCacheForReading; // This is used to prevent flooding the cache due to exponential bitwise combinations of flags. // Since multiple threads can add to the cache, a few more values might be added. private const int NameCacheSizeSoftLimit = 64; - public override bool CanConvert(Type type) + public EnumConverter(EnumConverterOptions converterOptions, JsonNamingPolicy? namingPolicy, JsonSerializerOptions options) { - return type.IsEnum; - } + Debug.Assert(EnumConverterFactory.IsSupportedTypeCode(s_enumTypeCode)); - public EnumConverter(EnumConverterOptions converterOptions, JsonSerializerOptions serializerOptions) - : this(converterOptions, namingPolicy: null, serializerOptions) - { - } - - public EnumConverter(EnumConverterOptions converterOptions, JsonNamingPolicy? namingPolicy, JsonSerializerOptions serializerOptions) - { _converterOptions = converterOptions; _namingPolicy = namingPolicy; - _nameCacheForWriting = new ConcurrentDictionary(); + _enumFieldInfo = ResolveEnumFields(namingPolicy); + _enumFieldInfoIndex = new(StringComparer.OrdinalIgnoreCase); - if (namingPolicy != null) - { - _nameCacheForReading = new ConcurrentDictionary(); - } + _nameCacheForWriting = new(); + _nameCacheForReading = new(StringComparer.Ordinal); -#if NET - string[] names = Enum.GetNames(); - T[] values = Enum.GetValues(); -#else - string[] names = Enum.GetNames(Type); - Array values = Enum.GetValues(Type); -#endif - Debug.Assert(names.Length == values.Length); - - JavaScriptEncoder? encoder = serializerOptions.Encoder; - - for (int i = 0; i < names.Length; i++) + JavaScriptEncoder? encoder = options.Encoder; + foreach (EnumFieldInfo fieldInfo in _enumFieldInfo) { -#if NET - T value = values[i]; -#else - T value = (T)values.GetValue(i)!; -#endif - ulong key = ConvertToUInt64(value); - string name = names[i]; - - string jsonName = FormatJsonName(name, namingPolicy); - _nameCacheForWriting.TryAdd(key, JsonEncodedText.Encode(jsonName, encoder)); - _nameCacheForReading?.TryAdd(jsonName, value); + AddToEnumFieldIndex(fieldInfo); - // If enum contains special char, make it failed to serialize or deserialize. - if (name.AsSpan().IndexOfAny(',', ' ') >= 0) - { - ThrowHelper.ThrowInvalidOperationException_InvalidEnumTypeWithSpecialChar(typeof(T), name); - } + JsonEncodedText encodedName = JsonEncodedText.Encode(fieldInfo.JsonName, encoder); + _nameCacheForWriting.TryAdd(fieldInfo.Key, encodedName); + _nameCacheForReading.TryAdd(fieldInfo.JsonName, fieldInfo.Key); } - } - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - JsonTokenType token = reader.TokenType; - - if (token == JsonTokenType.String) + if (namingPolicy != null) { - if ((_converterOptions & EnumConverterOptions.AllowStrings) == 0) + // Additionally populate the field index with the default names of fields that used a naming policy. + // This is done to preserve backward compat: default names should still be recognized by the parser. + foreach (EnumFieldInfo fieldInfo in _enumFieldInfo) { - ThrowHelper.ThrowJsonException(); - return default; - } - -#if NET - if (TryParseEnumCore(ref reader, out T value)) -#else - string? enumString = reader.GetString(); - if (TryParseEnumCore(enumString, out T value)) -#endif - { - return value; + if (fieldInfo.Kind is EnumFieldNameKind.NamingPolicy) + { + AddToEnumFieldIndex(new EnumFieldInfo(fieldInfo.Key, EnumFieldNameKind.Default, fieldInfo.OriginalName, fieldInfo.OriginalName)); + } } - -#if NET - return ReadEnumUsingNamingPolicy(reader.GetString()); -#else - return ReadEnumUsingNamingPolicy(enumString); -#endif } - if (token != JsonTokenType.Number || (_converterOptions & EnumConverterOptions.AllowNumbers) == 0) + void AddToEnumFieldIndex(EnumFieldInfo fieldInfo) { - ThrowHelper.ThrowJsonException(); - return default; + if (!_enumFieldInfoIndex.TryAdd(fieldInfo.JsonName, fieldInfo)) + { + // We have a casing conflict, append field to the existing entry. + EnumFieldInfo existingFieldInfo = _enumFieldInfoIndex[fieldInfo.JsonName]; + existingFieldInfo.AppendConflictingField(fieldInfo); + } } + } - switch (s_enumTypeCode) + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) { - // Switch cases ordered by expected frequency - - case TypeCode.Int32: - if (reader.TryGetInt32(out int int32)) - { - // Use Unsafe.As instead of raw pointers for .NET Standard support. - // https://github.com/dotnet/runtime/issues/84895 - return Unsafe.As(ref int32); - } - break; - case TypeCode.UInt32: - if (reader.TryGetUInt32(out uint uint32)) - { - return Unsafe.As(ref uint32); - } - break; - case TypeCode.UInt64: - if (reader.TryGetUInt64(out ulong uint64)) - { - return Unsafe.As(ref uint64); - } - break; - case TypeCode.Int64: - if (reader.TryGetInt64(out long int64)) - { - return Unsafe.As(ref int64); - } - break; - case TypeCode.SByte: - if (reader.TryGetSByte(out sbyte byte8)) - { - return Unsafe.As(ref byte8); - } - break; - case TypeCode.Byte: - if (reader.TryGetByte(out byte ubyte8)) - { - return Unsafe.As(ref ubyte8); - } - break; - case TypeCode.Int16: - if (reader.TryGetInt16(out short int16)) + case JsonTokenType.String when (_converterOptions & EnumConverterOptions.AllowStrings) != 0: + if (TryParseEnumFromString(ref reader, out T result)) { - return Unsafe.As(ref int16); + return result; } break; - case TypeCode.UInt16: - if (reader.TryGetUInt16(out ushort uint16)) + + case JsonTokenType.Number when (_converterOptions & EnumConverterOptions.AllowNumbers) != 0: + switch (s_enumTypeCode) { - return Unsafe.As(ref uint16); + case TypeCode.Int32 when reader.TryGetInt32(out int int32): return Unsafe.As(ref int32); + case TypeCode.UInt32 when reader.TryGetUInt32(out uint uint32): return Unsafe.As(ref uint32); + case TypeCode.Int64 when reader.TryGetInt64(out long int64): return Unsafe.As(ref int64); + case TypeCode.UInt64 when reader.TryGetUInt64(out ulong uint64): return Unsafe.As(ref uint64); + case TypeCode.Byte when reader.TryGetByte(out byte ubyte8): return Unsafe.As(ref ubyte8); + case TypeCode.SByte when reader.TryGetSByte(out sbyte byte8): return Unsafe.As(ref byte8); + case TypeCode.Int16 when reader.TryGetInt16(out short int16): return Unsafe.As(ref int16); + case TypeCode.UInt16 when reader.TryGetUInt16(out ushort uint16): return Unsafe.As(ref uint16); } break; } @@ -194,8 +132,8 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - // If strings are allowed, attempt to write it out as a string value - if ((_converterOptions & EnumConverterOptions.AllowStrings) != 0) + EnumConverterOptions converterOptions = _converterOptions; + if ((converterOptions & EnumConverterOptions.AllowStrings) != 0) { ulong key = ConvertToUInt64(value); @@ -205,19 +143,13 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions return; } - string original = value.ToString(); - - if (IsValidIdentifier(original)) + if (IsDefinedValueOrCombinationOfValues(key)) { - // We are dealing with a combination of flag constants since - // all constant values were cached during warm-up. - Debug.Assert(original.Contains(ValueSeparator)); - - original = FormatJsonName(original, _namingPolicy); - + Debug.Assert(s_isFlagsEnum, "Should only be entered by flags enums."); + string stringValue = FormatEnumAsString(key, value, dictionaryKeyPolicy: null); if (_nameCacheForWriting.Count < NameCacheSizeSoftLimit) { - formatted = JsonEncodedText.Encode(original, options.Encoder); + formatted = JsonEncodedText.Encode(stringValue, options.Encoder); writer.WriteStringValue(formatted); _nameCacheForWriting.TryAdd(key, formatted); } @@ -225,97 +157,60 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions { // We also do not create a JsonEncodedText instance here because passing the string // directly to the writer is cheaper than creating one and not caching it for reuse. - writer.WriteStringValue(original); + writer.WriteStringValue(stringValue); } return; } } - if ((_converterOptions & EnumConverterOptions.AllowNumbers) == 0) + if ((converterOptions & EnumConverterOptions.AllowNumbers) == 0) { ThrowHelper.ThrowJsonException(); } - switch (s_enumTypeCode) + if (s_isSignedEnum) { - case TypeCode.Int32: - // Use Unsafe.As instead of raw pointers for .NET Standard support. - // https://github.com/dotnet/runtime/issues/84895 - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - case TypeCode.UInt32: - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - case TypeCode.UInt64: - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - case TypeCode.Int64: - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - case TypeCode.Int16: - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - case TypeCode.UInt16: - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - case TypeCode.Byte: - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - case TypeCode.SByte: - writer.WriteNumberValue(Unsafe.As(ref value)); - break; - default: - ThrowHelper.ThrowJsonException(); - break; + writer.WriteNumberValue(ConvertToInt64(value)); + } + else + { + writer.WriteNumberValue(ConvertToUInt64(value)); } } internal override T ReadAsPropertyNameCore(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { -#if NET - if (TryParseEnumCore(ref reader, out T value)) -#else - string? enumString = reader.GetString(); - if (TryParseEnumCore(reader.GetString(), out T value)) -#endif + // NB JsonSerializerOptions.DictionaryKeyPolicy is ignored on deserialization. + // This is true for all converters that implement dictionary key serialization. + + if (!TryParseEnumFromString(ref reader, out T result)) { - return value; + ThrowHelper.ThrowJsonException(); } -#if NET - return ReadEnumUsingNamingPolicy(reader.GetString()); -#else - return ReadEnumUsingNamingPolicy(enumString); -#endif + return result; } internal override void WriteAsPropertyNameCore(Utf8JsonWriter writer, T value, JsonSerializerOptions options, bool isWritingExtensionDataProperty) { + JsonNamingPolicy? dictionaryKeyPolicy = options.DictionaryKeyPolicy is { } dkp && dkp != _namingPolicy ? dkp : null; ulong key = ConvertToUInt64(value); - if (options.DictionaryKeyPolicy == null && _nameCacheForWriting.TryGetValue(key, out JsonEncodedText formatted)) + if (dictionaryKeyPolicy is null && _nameCacheForWriting.TryGetValue(key, out JsonEncodedText formatted)) { writer.WritePropertyName(formatted); return; } - string original = value.ToString(); - - if (IsValidIdentifier(original)) + if (IsDefinedValueOrCombinationOfValues(key)) { - if (options.DictionaryKeyPolicy != null) + Debug.Assert(s_isFlagsEnum || dictionaryKeyPolicy != null, "Should only be entered by flags enums or dictionary key policy."); + string stringValue = FormatEnumAsString(key, value, dictionaryKeyPolicy); + if (dictionaryKeyPolicy is null && _nameCacheForWriting.Count < NameCacheSizeSoftLimit) { - original = FormatJsonName(original, options.DictionaryKeyPolicy); - writer.WritePropertyName(original); - return; - } - - original = FormatJsonName(original, _namingPolicy); - - if (_nameCacheForWriting.Count < NameCacheSizeSoftLimit) - { - formatted = JsonEncodedText.Encode(original, options.Encoder); + // Only attempt to cache if there is no dictionary key policy. + formatted = JsonEncodedText.Encode(stringValue, options.Encoder); writer.WritePropertyName(formatted); _nameCacheForWriting.TryAdd(key, formatted); } @@ -323,250 +218,440 @@ internal override void WriteAsPropertyNameCore(Utf8JsonWriter writer, T value, J { // We also do not create a JsonEncodedText instance here because passing the string // directly to the writer is cheaper than creating one and not caching it for reuse. - writer.WritePropertyName(original); + writer.WritePropertyName(stringValue); } return; } - switch (s_enumTypeCode) + if (s_isSignedEnum) { - // Use Unsafe.As instead of raw pointers for .NET Standard support. - // https://github.com/dotnet/runtime/issues/84895 - - case TypeCode.Int32: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - case TypeCode.UInt32: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - case TypeCode.UInt64: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - case TypeCode.Int64: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - case TypeCode.Int16: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - case TypeCode.UInt16: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - case TypeCode.Byte: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - case TypeCode.SByte: - writer.WritePropertyName(Unsafe.As(ref value)); - break; - default: - ThrowHelper.ThrowJsonException(); - break; + writer.WritePropertyName(ConvertToInt64(value)); + } + else + { + writer.WritePropertyName(key); } } - private bool TryParseEnumCore( -#if NET - ref Utf8JsonReader reader, -#else - string? source, -#endif - out T value) + private bool TryParseEnumFromString(ref Utf8JsonReader reader, out T result) { -#if NET - char[]? rentedBuffer = null; + Debug.Assert(reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName); + int bufferLength = reader.ValueLength; + char[]? rentedBuffer = null; + bool success; Span charBuffer = bufferLength <= JsonConstants.StackallocCharThreshold ? stackalloc char[JsonConstants.StackallocCharThreshold] : (rentedBuffer = ArrayPool.Shared.Rent(bufferLength)); int charsWritten = reader.CopyString(charBuffer); - ReadOnlySpan source = charBuffer.Slice(0, charsWritten); + charBuffer = charBuffer.Slice(0, charsWritten); +#if NET9_0_OR_GREATER + ReadOnlySpan source = charBuffer.Trim(); + ConcurrentDictionary.AlternateLookup> lookup = _nameCacheForReading.GetAlternateLookup>(); +#else + string source = ((ReadOnlySpan)charBuffer).Trim().ToString(); + ConcurrentDictionary lookup = _nameCacheForReading; #endif + if (lookup.TryGetValue(source, out ulong key)) + { + result = ConvertFromUInt64(key); + success = true; + goto End; + } - bool success; - if ((_converterOptions & EnumConverterOptions.AllowNumbers) != 0 || !JsonHelpers.IntegerRegex.IsMatch(source)) + if (JsonHelpers.IntegerRegex.IsMatch(source)) { - // Try parsing case sensitive first - success = Enum.TryParse(source, out value) || Enum.TryParse(source, ignoreCase: true, out value); + // We found an integer that is not an enum field name. + if ((_converterOptions & EnumConverterOptions.AllowNumbers) != 0) + { + success = Enum.TryParse(source, out result); + } + else + { + result = default; + success = false; + } } else { - success = false; - value = default; + success = TryParseNamedEnum(source, out result); } -#if NET + if (success && _nameCacheForReading.Count < NameCacheSizeSoftLimit) + { + lookup.TryAdd(source, ConvertToUInt64(result)); + } + + End: if (rentedBuffer != null) { - charBuffer.Slice(0, charsWritten).Clear(); + charBuffer.Clear(); ArrayPool.Shared.Return(rentedBuffer); } -#endif + return success; } - internal override JsonSchema? GetSchema(JsonNumberHandling numberHandling) + private bool TryParseNamedEnum( +#if NET9_0_OR_GREATER + ReadOnlySpan source, +#else + string source, +#endif + out T result) { - if ((_converterOptions & EnumConverterOptions.AllowStrings) != 0) - { - // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings - // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. +#if NET9_0_OR_GREATER + Dictionary.AlternateLookup> lookup = _enumFieldInfoIndex.GetAlternateLookup>(); + ReadOnlySpan rest = source; +#else + Dictionary lookup = _enumFieldInfoIndex; + ReadOnlySpan rest = source.AsSpan(); +#endif + ulong key = 0; - if (s_isFlagsEnum) + do + { + ReadOnlySpan next; + int i = rest.IndexOf(','); + if (i == -1) { - // Do not report enum values in case of flags. - return new() { Type = JsonSchemaType.String }; + next = rest; + rest = default; + } + else + { + next = rest.Slice(0, i).TrimEnd(); + rest = rest.Slice(i + 1).TrimStart(); } - JsonNamingPolicy? namingPolicy = _namingPolicy; - JsonArray enumValues = []; -#if NET - string[] names = Enum.GetNames(); + if (lookup.TryGetValue( +#if NET9_0_OR_GREATER + next, #else - string[] names = Enum.GetNames(Type); + next.ToString(), #endif - - for (int i = 0; i < names.Length; i++) + out EnumFieldInfo? firstResult) && + firstResult.GetMatchingField(next) is EnumFieldInfo match) { - JsonNode name = FormatJsonName(names[i], namingPolicy); - enumValues.Add(name); + key |= match.Key; + continue; } - return new() { Enum = enumValues }; - } + result = default; + return false; - return new() { Type = JsonSchemaType.Integer }; + } while (!rest.IsEmpty); + + result = ConvertFromUInt64(key); + return true; } - private T ReadEnumUsingNamingPolicy(string? enumString) + private static ulong ConvertToUInt64(T value) { - if (_namingPolicy == null) + switch (s_enumTypeCode) { - ThrowHelper.ThrowJsonException(); - } + case TypeCode.Int32 or TypeCode.UInt32: return Unsafe.As(ref value); + case TypeCode.Int64 or TypeCode.UInt64: return Unsafe.As(ref value); + case TypeCode.Int16 or TypeCode.UInt16: return Unsafe.As(ref value); + default: + Debug.Assert(s_enumTypeCode is TypeCode.SByte or TypeCode.Byte); + return Unsafe.As(ref value); + }; + } - if (enumString == null) + private static long ConvertToInt64(T value) + { + Debug.Assert(s_isSignedEnum); + switch (s_enumTypeCode) { - ThrowHelper.ThrowJsonException(); - } + case TypeCode.Int32: return Unsafe.As(ref value); + case TypeCode.Int64: return Unsafe.As(ref value); + case TypeCode.Int16: return Unsafe.As(ref value); + default: + Debug.Assert(s_enumTypeCode is TypeCode.SByte); + return Unsafe.As(ref value); + }; + } - Debug.Assert(_nameCacheForReading != null, "Enum value cache should be instantiated if a naming policy is specified."); + private static T ConvertFromUInt64(ulong value) + { + switch (s_enumTypeCode) + { + case TypeCode.Int32 or TypeCode.UInt32: + uint uintValue = (uint)value; + return Unsafe.As(ref uintValue); - bool success; + case TypeCode.Int64 or TypeCode.UInt64: + ulong ulongValue = value; + return Unsafe.As(ref ulongValue); + + case TypeCode.Int16 or TypeCode.UInt16: + ushort ushortValue = (ushort)value; + return Unsafe.As(ref ushortValue); + + default: + Debug.Assert(s_enumTypeCode is TypeCode.SByte or TypeCode.Byte); + byte byteValue = (byte)value; + return Unsafe.As(ref byteValue); + }; + } - if (!(success = _nameCacheForReading.TryGetValue(enumString, out T value)) && enumString.Contains(ValueSeparator)) + /// + /// Attempt to format the enum value as a comma-separated string of flag values, or returns false if not a valid flag combination. + /// + private string FormatEnumAsString(ulong key, T value, JsonNamingPolicy? dictionaryKeyPolicy) + { + Debug.Assert(IsDefinedValueOrCombinationOfValues(key), "must only be invoked against valid enum values."); + Debug.Assert( + s_isFlagsEnum || (dictionaryKeyPolicy is not null && Enum.IsDefined(typeof(T), value)), + "must either be a flag type or computing a dictionary key policy."); + + if (s_isFlagsEnum) { - string[] enumValues = SplitFlagsEnum(enumString); - ulong result = 0; + using ValueStringBuilder sb = new(stackalloc char[JsonConstants.StackallocCharThreshold]); + ulong remainingBits = key; - for (int i = 0; i < enumValues.Length; i++) + foreach (EnumFieldInfo enumField in _enumFieldInfo) { - success = _nameCacheForReading.TryGetValue(enumValues[i], out value); - if (!success) + ulong fieldKey = enumField.Key; + if (fieldKey == 0 ? key == 0 : (remainingBits & fieldKey) == fieldKey) { - break; + remainingBits &= ~fieldKey; + string name = dictionaryKeyPolicy is not null + ? ResolveAndValidateJsonName(enumField.OriginalName, dictionaryKeyPolicy, enumField.Kind) + : enumField.JsonName; + + if (sb.Length > 0) + { + sb.Append(", "); + } + + sb.Append(name); + + if (remainingBits == 0) + { + break; + } } - - result |= ConvertToUInt64(value); } - value = (T)Enum.ToObject(typeof(T), result); + Debug.Assert(remainingBits == 0 && sb.Length > 0, "unexpected remaining bits or empty string."); + return sb.ToString(); + } + else + { + Debug.Assert(dictionaryKeyPolicy != null); - if (success && _nameCacheForReading.Count < NameCacheSizeSoftLimit) + foreach (EnumFieldInfo enumField in _enumFieldInfo) { - _nameCacheForReading[enumString] = value; + // Search for an exact match and apply the key policy. + if (enumField.Key == key) + { + return ResolveAndValidateJsonName(enumField.OriginalName, dictionaryKeyPolicy, enumField.Kind); + } } + + Debug.Fail("should not have been reached."); + return null; } + } - if (!success) + private bool IsDefinedValueOrCombinationOfValues(ulong key) + { + if (s_isFlagsEnum) { - ThrowHelper.ThrowJsonException(); + ulong remainingBits = key; + + foreach (EnumFieldInfo fieldInfo in _enumFieldInfo) + { + ulong fieldKey = fieldInfo.Key; + if (fieldKey == 0 ? key == 0 : (remainingBits & fieldKey) == fieldKey) + { + remainingBits &= ~fieldKey; + + if (remainingBits == 0) + { + return true; + } + } + } + + return false; } + else + { + foreach (EnumFieldInfo fieldInfo in _enumFieldInfo) + { + if (fieldInfo.Key == key) + { + return true; + } + } - return value; + return false; + } } - // This method is adapted from Enum.ToUInt64 (an internal method): - // https://github.com/dotnet/runtime/blob/bd6cbe3642f51d70839912a6a666e5de747ad581/src/libraries/System.Private.CoreLib/src/System/Enum.cs#L240-L260 - private static ulong ConvertToUInt64(object value) + internal override JsonSchema? GetSchema(JsonNumberHandling numberHandling) { - Debug.Assert(value is T); - ulong result = s_enumTypeCode switch - { - TypeCode.Int32 => (ulong)(int)value, - TypeCode.UInt32 => (uint)value, - TypeCode.UInt64 => (ulong)value, - TypeCode.Int64 => (ulong)(long)value, - TypeCode.SByte => (ulong)(sbyte)value, - TypeCode.Byte => (byte)value, - TypeCode.Int16 => (ulong)(short)value, - TypeCode.UInt16 => (ushort)value, - _ => throw new InvalidOperationException(), - }; - return result; + if ((_converterOptions & EnumConverterOptions.AllowStrings) != 0) + { + // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings + // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. + + if (s_isFlagsEnum) + { + // Do not report enum values in case of flags. + return new() { Type = JsonSchemaType.String }; + } + + JsonArray enumValues = []; + foreach (EnumFieldInfo fieldInfo in _enumFieldInfo) + { + enumValues.Add((JsonNode)fieldInfo.JsonName); + } + + return new() { Enum = enumValues }; + } + + return new() { Type = JsonSchemaType.Integer }; } - private static bool IsValidIdentifier(string value) + private static EnumFieldInfo[] ResolveEnumFields(JsonNamingPolicy? namingPolicy) { - // Trying to do this check efficiently. When an enum is converted to - // string the underlying value is given if it can't find a matching - // identifier (or identifiers in the case of flags). - // - // The underlying value will be given back with a digit (e.g. 0-9) possibly - // preceded by a negative sign. Identifiers have to start with a letter - // so we'll just pick the first valid one and check for a negative sign - // if needed. - return (value[0] >= 'A' && - (!s_isSignedEnum || !value.StartsWith(NumberFormatInfo.CurrentInfo.NegativeSign))); +#if NET + string[] names = Enum.GetNames(); + T[] values = Enum.GetValues(); +#else + string[] names = Enum.GetNames(typeof(T)); + T[] values = (T[])Enum.GetValues(typeof(T)); +#endif + Debug.Assert(names.Length == values.Length); + + Dictionary? enumMemberAttributes = null; + foreach (FieldInfo field in typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.GetCustomAttribute() is { } attribute) + { + (enumMemberAttributes ??= new(StringComparer.Ordinal)).Add(field.Name, attribute.Name); + } + } + + var enumFields = new EnumFieldInfo[names.Length]; + for (int i = 0; i < names.Length; i++) + { + string originalName = names[i]; + T value = values[i]; + ulong key = ConvertToUInt64(value); + EnumFieldNameKind kind; + + if (enumMemberAttributes != null && enumMemberAttributes.TryGetValue(originalName, out string? attributeName)) + { + originalName = attributeName; + kind = EnumFieldNameKind.Attribute; + } + else + { + kind = namingPolicy != null ? EnumFieldNameKind.NamingPolicy : EnumFieldNameKind.Default; + } + + string jsonName = ResolveAndValidateJsonName(originalName, namingPolicy, kind); + enumFields[i] = new EnumFieldInfo(key, kind, originalName, jsonName); + } + + return enumFields; } - private static string FormatJsonName(string value, JsonNamingPolicy? namingPolicy) + private static string ResolveAndValidateJsonName(string name, JsonNamingPolicy? namingPolicy, EnumFieldNameKind kind) { - if (namingPolicy is null) + if (kind is not EnumFieldNameKind.Attribute && namingPolicy is not null) { - return value; + // Do not apply a naming policy to names that are explicitly set via attributes. + // This is consistent with JsonPropertyNameAttribute semantics. + name = namingPolicy.ConvertName(name); } - string converted; - if (!value.Contains(ValueSeparator)) + if (string.IsNullOrEmpty(name) || char.IsWhiteSpace(name[0]) || char.IsWhiteSpace(name[name.Length - 1]) || + (s_isFlagsEnum && name.AsSpan().IndexOf(',') >= 0)) + { + // Reject null or empty strings or strings with leading or trailing whitespace. + // In the case of flags additionally reject strings containing commas. + ThrowHelper.ThrowInvalidOperationException_UnsupportedEnumIdentifier(typeof(T), name); + } + + return name; + } + + private sealed class EnumFieldInfo(ulong key, EnumFieldNameKind kind, string originalName, string jsonName) + { + private List? _conflictingFields; + public EnumFieldNameKind Kind { get; } = kind; + public ulong Key { get; } = key; + public string OriginalName { get; } = originalName; + public string JsonName { get; } = jsonName; + + /// + /// Assuming we have field that conflicts with the current up to case sensitivity, + /// append it to a list of trailing values for use by the enum value parser. + /// + public void AppendConflictingField(EnumFieldInfo other) { - converted = namingPolicy.ConvertName(value); - if (converted == null) + Debug.Assert(JsonName.Equals(other.JsonName, StringComparison.OrdinalIgnoreCase), "The conflicting entry must be equal up to case insensitivity."); + + if (Kind is EnumFieldNameKind.Default || JsonName.Equals(other.JsonName, StringComparison.Ordinal)) { - ThrowHelper.ThrowInvalidOperationException_NamingPolicyReturnNull(namingPolicy); + // Silently discard if the preceding entry is the default or has identical name. + return; } + + List conflictingFields = _conflictingFields ??= []; + + // Walk the existing list to ensure we do not add duplicates. + foreach (EnumFieldInfo conflictingField in conflictingFields) + { + if (conflictingField.Kind is EnumFieldNameKind.Default || conflictingField.JsonName.Equals(other.JsonName, StringComparison.Ordinal)) + { + return; + } + } + + conflictingFields.Add(other); } - else + + public EnumFieldInfo? GetMatchingField(ReadOnlySpan input) { - string[] enumValues = SplitFlagsEnum(value); + Debug.Assert(input.Equals(JsonName.AsSpan(), StringComparison.OrdinalIgnoreCase), "Must equal the field name up to case insensitivity."); + + if (Kind is EnumFieldNameKind.Default || input.SequenceEqual(JsonName.AsSpan())) + { + // Default enum names use case insensitive parsing so are always a match. + return this; + } - for (int i = 0; i < enumValues.Length; i++) + if (_conflictingFields is { } conflictingFields) { - string name = namingPolicy.ConvertName(enumValues[i]); - if (name == null) + Debug.Assert(conflictingFields.Count > 0); + foreach (EnumFieldInfo matchingField in conflictingFields) { - ThrowHelper.ThrowInvalidOperationException_NamingPolicyReturnNull(namingPolicy); + if (matchingField.Kind is EnumFieldNameKind.Default || input.SequenceEqual(matchingField.JsonName.AsSpan())) + { + return matchingField; + } } - enumValues[i] = name; } - converted = string.Join(ValueSeparator, enumValues); + return null; } - - return converted; } - private static string[] SplitFlagsEnum(string value) + private enum EnumFieldNameKind { - // todo: optimize implementation here by leveraging https://github.com/dotnet/runtime/issues/934. - return value.Split( -#if NET - ValueSeparator -#else - new string[] { ValueSeparator }, StringSplitOptions.None -#endif - ); + Default = 0, + NamingPolicy = 1, + Attribute = 2, } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverterFactory.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverterFactory.cs index ed49845dcfb70..26b4979f4633d 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverterFactory.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverterFactory.cs @@ -3,12 +3,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Reflection; namespace System.Text.Json.Serialization.Converters { - [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] internal sealed class EnumConverterFactory : JsonConverterFactory { + [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] public EnumConverterFactory() { } @@ -18,23 +19,48 @@ public override bool CanConvert(Type type) return type.IsEnum; } + public static bool IsSupportedTypeCode(TypeCode typeCode) + { + return typeCode is TypeCode.SByte or TypeCode.Int16 or TypeCode.Int32 or TypeCode.Int64 + or TypeCode.Byte or TypeCode.UInt16 or TypeCode.UInt32 or TypeCode.UInt64; + } + + [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", + Justification = "The constructor has been annotated with RequiredDynamicCodeAttribute.")] public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) { Debug.Assert(CanConvert(type)); return Create(type, EnumConverterOptions.AllowNumbers, namingPolicy: null, options); } - internal static JsonConverter Create(Type enumType, EnumConverterOptions converterOptions, JsonNamingPolicy? namingPolicy, JsonSerializerOptions options) + public static JsonConverter Create(EnumConverterOptions converterOptions, JsonSerializerOptions options, JsonNamingPolicy? namingPolicy = null) + where T : struct, Enum { - return (JsonConverter)Activator.CreateInstance( - GetEnumConverterType(enumType), - new object?[] { converterOptions, namingPolicy, options })!; + if (!IsSupportedTypeCode(Type.GetTypeCode(typeof(T)))) + { + // Char-backed enums are valid in IL and F# but are not supported by System.Text.Json. + return new UnsupportedTypeConverter(); + } + + return new EnumConverter(converterOptions, namingPolicy, options); } + [RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)] [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070:UnrecognizedReflectionPattern", Justification = "'EnumConverter where T : struct' implies 'T : new()', so the trimmer is warning calling MakeGenericType here because enumType's constructors are not annotated. " + "But EnumConverter doesn't call new T(), so this is safe.")] - [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] - private static Type GetEnumConverterType(Type enumType) => typeof(EnumConverter<>).MakeGenericType(enumType); + public static JsonConverter Create(Type enumType, EnumConverterOptions converterOptions, JsonNamingPolicy? namingPolicy, JsonSerializerOptions options) + { + if (!IsSupportedTypeCode(Type.GetTypeCode(enumType))) + { + // Char-backed enums are valid in IL and F# but are not supported by System.Text.Json. + return UnsupportedTypeConverterFactory.CreateUnsupportedConverterForType(enumType); + } + + Type converterType = typeof(EnumConverter<>).MakeGenericType(enumType); + return (JsonConverter)converterType.CreateInstanceNoWrapExceptions( + parameterTypes: [typeof(EnumConverterOptions), typeof(JsonNamingPolicy), typeof(JsonSerializerOptions)], + parameters: [converterOptions, namingPolicy, options])!; + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNumberEnumConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNumberEnumConverter.cs index fa7c190f4f3ea..9ee2ef5eb9ae7 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNumberEnumConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNumberEnumConverter.cs @@ -33,7 +33,7 @@ public JsonNumberEnumConverter() { } ThrowHelper.ThrowArgumentOutOfRangeException_JsonConverterFactory_TypeNotSupported(typeToConvert); } - return new EnumConverter(EnumConverterOptions.AllowNumbers, options); + return EnumConverterFactory.Create(EnumConverterOptions.AllowNumbers, options); } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumConverter.cs index 9a74546b87238..384f1336cc85b 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumConverter.cs @@ -57,7 +57,7 @@ public JsonStringEnumConverter(JsonNamingPolicy? namingPolicy = null, bool allow ThrowHelper.ThrowArgumentOutOfRangeException_JsonConverterFactory_TypeNotSupported(typeToConvert); } - return new EnumConverter(_converterOptions, _namingPolicy, options); + return EnumConverterFactory.Create(_converterOptions, options, _namingPolicy); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs new file mode 100644 index 0000000000000..192588e2909b8 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Text.Json.Serialization +{ + /// + /// Determines the string value that should be used when serializing an enum member. + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] + public class JsonStringEnumMemberNameAttribute : Attribute + { + /// + /// Creates new attribute instance with a specified enum member name. + /// + /// The name to apply to the current enum member. + public JsonStringEnumMemberNameAttribute(string name) + { + Name = name; + } + + /// + /// Gets the name of the enum member. + /// + public string Name { get; } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs index 9c6cb4b9e3ce3..d260844f6c05d 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonMetadataServices.Converters.cs @@ -287,7 +287,7 @@ public static JsonConverter GetEnumConverter(JsonSerializerOptions options ThrowHelper.ThrowArgumentNullException(nameof(options)); } - return new EnumConverter(EnumConverterOptions.AllowNumbers, options); + return EnumConverterFactory.Create(EnumConverterOptions.AllowNumbers, options); } /// diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs index a793d42abdaea..fed9efb84d031 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs @@ -910,9 +910,9 @@ public static void ThrowInvalidOperationException_PolymorphicTypeConfigurationDo } [DoesNotReturn] - public static void ThrowInvalidOperationException_InvalidEnumTypeWithSpecialChar(Type enumType, string enumName) + public static void ThrowInvalidOperationException_UnsupportedEnumIdentifier(Type enumType, string? enumName) { - throw new InvalidOperationException(SR.Format(SR.InvalidEnumTypeWithSpecialChar, enumType.Name, enumName)); + throw new InvalidOperationException(SR.Format(SR.UnsupportedEnumIdentifier, enumType.Name, enumName)); } [DoesNotReturn] diff --git a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.Dictionary.KeyPolicy.cs b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.Dictionary.KeyPolicy.cs index ed7aad9556f67..c0f831383549c 100644 --- a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.Dictionary.KeyPolicy.cs +++ b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.Dictionary.KeyPolicy.cs @@ -253,7 +253,6 @@ public async Task EnumSerialization_DictionaryPolicy_NotApplied_WhenEnumsAreSeri Assert.Equal("2", value); - value = await Serializer.SerializeWrapper(new ClassWithEnumProperties(), options); Assert.Equal("{\"TestEnumProperty1\":2,\"TestEnumProperty2\":1}", value); @@ -280,7 +279,7 @@ public async Task EnumSerialization_DictionaryPolicy_ThrowsException_WhenNamingP InvalidOperationException ex = await Assert.ThrowsAsync(() => Serializer.SerializeWrapper(dict, options)); - Assert.Contains(typeof(CustomJsonNamingPolicy).ToString(), ex.Message); + Assert.Contains("uses unsupported identifier", ex.Message); } [Fact] diff --git a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs index d3aa23ea94851..35949a9a68a30 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs @@ -93,6 +93,10 @@ public static IEnumerable GetTestDataCore() yield return new TestData(IntEnum.A, ExpectedJsonSchema: """{"type":"integer"}"""); yield return new TestData(StringEnum.A, ExpectedJsonSchema: """{"enum":["A","B","C"]}"""); yield return new TestData(FlagsStringEnum.A, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData( + EnumWithNameAttributes.Value1, + AdditionalValues: [EnumWithNameAttributes.Value2], + ExpectedJsonSchema: """{"enum":["A","B"]}"""); // Nullable types yield return new TestData(true, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["boolean","null"]}"""); @@ -1077,6 +1081,15 @@ public enum StringEnum { A, B, C }; [Flags, JsonConverter(typeof(JsonStringEnumConverter))] public enum FlagsStringEnum { A = 1, B = 2, C = 4 }; + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum EnumWithNameAttributes + { + [JsonStringEnumMemberName("A")] + Value1 = 1, + [JsonStringEnumMemberName("B")] + Value2 = 2, + } + public class SimplePoco { public string String { get; set; } = "default"; diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs b/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs index 07f1e2dc6a3da..98a140746c5f2 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs @@ -34,16 +34,14 @@ optionsDisableNumeric.Converters.Add(new JsonStringEnumConverter(null, false)) [] let ``Deserialize With Exception If Enum Contains Special Char`` () = - let ex = Assert.Throws(fun () -> JsonSerializer.Deserialize(badEnumJsonStr, options) |> ignore) - Assert.Equal(typeof, ex.InnerException.GetType()) - Assert.Equal("Enum type 'BadEnum' uses unsupported identifer name 'There's a comma, in my name'.", ex.InnerException.Message) + let ex = Assert.Throws(fun () -> JsonSerializer.Deserialize(badEnumJsonStr, options) |> ignore) + Assert.Contains("Enum type 'BadEnum' uses unsupported identifier 'There's a comma, in my name'.", ex.Message) [] let ``Serialize With Exception If Enum Contains Special Char`` () = - let ex = Assert.Throws(fun () -> JsonSerializer.Serialize(badEnum, options) |> ignore) - Assert.Equal(typeof, ex.InnerException.GetType()) - Assert.Equal("Enum type 'BadEnum' uses unsupported identifer name 'There's a comma, in my name'.", ex.InnerException.Message) + let ex = Assert.Throws(fun () -> JsonSerializer.Serialize(badEnum, options) |> ignore) + Assert.Contains("Enum type 'BadEnum' uses unsupported identifier 'There's a comma, in my name'.", ex.Message) [] let ``Successful Deserialize Normal Enum`` () = @@ -52,15 +50,13 @@ let ``Successful Deserialize Normal Enum`` () = [] let ``Fail Deserialize Good Value Of Bad Enum Type`` () = - let ex = Assert.Throws(fun () -> JsonSerializer.Deserialize(badEnumWithGoodValueJsonStr, options) |> ignore) - Assert.Equal(typeof, ex.InnerException.GetType()) - Assert.Equal("Enum type 'BadEnum' uses unsupported identifer name 'There's a comma, in my name'.", ex.InnerException.Message) + let ex = Assert.Throws(fun () -> JsonSerializer.Deserialize(badEnumWithGoodValueJsonStr, options) |> ignore) + Assert.Contains("Enum type 'BadEnum' uses unsupported identifier 'There's a comma, in my name'.", ex.Message) [] let ``Fail Serialize Good Value Of Bad Enum Type`` () = - let ex = Assert.Throws(fun () -> JsonSerializer.Serialize(badEnumWithGoodValue, options) |> ignore) - Assert.Equal(typeof, ex.InnerException.GetType()) - Assert.Equal("Enum type 'BadEnum' uses unsupported identifer name 'There's a comma, in my name'.", ex.InnerException.Message) + let ex = Assert.Throws(fun () -> JsonSerializer.Serialize(badEnumWithGoodValue, options) |> ignore) + Assert.Contains("Enum type 'BadEnum' uses unsupported identifier 'There's a comma, in my name'.", ex.Message) type NumericLabelEnum = | ``1`` = 1 @@ -68,18 +64,23 @@ type NumericLabelEnum = | ``3`` = 4 [] -[] -[] -[] [] [] [] [] -[] [] [] let ``Fail Deserialize Numeric label Of Enum When Disallow Integer Values`` (numericValueJsonStr: string) = Assert.Throws(fun () -> JsonSerializer.Deserialize(numericValueJsonStr, optionsDisableNumeric) |> ignore) + +[] +[] +[] +[] +[] +let ``Successful Deserialize Numeric label Of Enum When Disallow Integer Values If Matching Integer Label`` (numericValueJsonStr: string, expectedValue: NumericLabelEnum) = + let actual = JsonSerializer.Deserialize(numericValueJsonStr, optionsDisableNumeric) + Assert.Equal(expectedValue, actual) [] [] @@ -88,8 +89,24 @@ let ``Successful Deserialize Numeric label Of Enum When Allowing Integer Values` let actual = JsonSerializer.Deserialize(numericValueJsonStr, options) Assert.Equal(expectedEnumValue, actual) +[] +[] +[] +[] +[] +[] +let ``Successful Deserialize Numeric label Of Enum But as Underlying value When Allowing Integer Values`` (numericValue: int) = + let actual = JsonSerializer.Deserialize($"\"{numericValue}\"", options) + Assert.Equal(LanguagePrimitives.EnumOfValue numericValue, actual) + +type CharEnum = + | A = 'A' + | B = 'B' + | C = 'C' + [] -let ``Successful Deserialize Numeric label Of Enum But as Underlying value When Allowing Integer Values`` () = - let actual = JsonSerializer.Deserialize("\"3\"", options) - Assert.NotEqual(NumericLabelEnum.``3``, actual) - Assert.Equal(LanguagePrimitives.EnumOfValue 3, actual) \ No newline at end of file +let ``Serializing char enums throws NotSupportedException`` () = + Assert.Throws(fun () -> JsonSerializer.Serialize(CharEnum.A) |> ignore) |> ignore + Assert.Throws(fun () -> JsonSerializer.Serialize(CharEnum.A, options) |> ignore) |> ignore + Assert.Throws(fun () -> JsonSerializer.Deserialize("0") |> ignore) |> ignore + Assert.Throws(fun () -> JsonSerializer.Deserialize("\"A\"", options) |> ignore) |> ignore diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs index f188c0f241909..a7b0775361de9 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs @@ -65,6 +65,7 @@ public sealed partial class JsonSchemaExporterTests_SourceGen() [JsonSerializable(typeof(IntEnum))] [JsonSerializable(typeof(StringEnum))] [JsonSerializable(typeof(FlagsStringEnum))] + [JsonSerializable(typeof(EnumWithNameAttributes))] // Nullable types [JsonSerializable(typeof(bool?))] [JsonSerializable(typeof(int?))] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs index 91f6473f78d71..4668dbc6260f1 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs @@ -1,20 +1,24 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Reflection; using System.Threading.Tasks; using Microsoft.DotNet.RemoteExecutor; +using Newtonsoft.Json; using Xunit; namespace System.Text.Json.Serialization.Tests { public class EnumConverterTests { + private static readonly JsonSerializerOptions s_optionsWithStringEnumConverter = new() { Converters = { new JsonStringEnumConverter() } }; + private static readonly JsonSerializerOptions s_optionsWithStringAndNoIntegerEnumConverter = new() { Converters = { new JsonStringEnumConverter(allowIntegerValues: false) } }; + [Theory] [InlineData(typeof(JsonStringEnumConverter), typeof(DayOfWeek))] [InlineData(typeof(JsonStringEnumConverter), typeof(MyCustomEnum))] @@ -832,5 +836,376 @@ private static JsonSerializerOptions CreateStringEnumOptionsForType(bool { return CreateStringEnumOptionsForType(typeof(TEnum), useGenericVariant, namingPolicy, allowIntegerValues); } + + [Theory] + [InlineData(EnumWithMemberAttributes.Value1, "CustomValue1")] + [InlineData(EnumWithMemberAttributes.Value2, "CustomValue2")] + [InlineData(EnumWithMemberAttributes.Value3, "Value3")] + public static void EnumWithMemberAttributes_StringEnumConverter_SerializesAsExpected(EnumWithMemberAttributes value, string expectedJson) + { + string json = JsonSerializer.Serialize(value, s_optionsWithStringEnumConverter); + Assert.Equal($"\"{expectedJson}\"", json); + Assert.Equal(value, JsonSerializer.Deserialize(json, s_optionsWithStringEnumConverter)); + } + + [Theory] + [InlineData(EnumWithMemberAttributes.Value1)] + [InlineData(EnumWithMemberAttributes.Value2)] + [InlineData(EnumWithMemberAttributes.Value3)] + public static void EnumWithMemberAttributes_NoStringEnumConverter_SerializesAsNumber(EnumWithMemberAttributes value) + { + string json = JsonSerializer.Serialize(value); + Assert.Equal($"{(int)value}", json); + Assert.Equal(value, JsonSerializer.Deserialize(json)); + } + + [Theory] + [InlineData(EnumWithMemberAttributes.Value1, "CustomValue1")] + [InlineData(EnumWithMemberAttributes.Value2, "CustomValue2")] + [InlineData(EnumWithMemberAttributes.Value3, "value3")] + public static void EnumWithMemberAttributes_StringEnumConverterWithNamingPolicy_NotAppliedToCustomNames(EnumWithMemberAttributes value, string expectedJson) + { + JsonSerializerOptions options = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; + + string json = JsonSerializer.Serialize(value, options); + Assert.Equal($"\"{expectedJson}\"", json); + Assert.Equal(value, JsonSerializer.Deserialize(json, options)); + } + + [Fact] + public static void EnumWithMemberAttributes_NamingPolicyAndDictionaryKeyPolicy_NotAppliedToCustomNames() + { + JsonSerializerOptions options = new() + { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + DictionaryKeyPolicy = JsonNamingPolicy.KebabCaseUpper, + }; + + Dictionary value = new() + { + [EnumWithMemberAttributes.Value1] = [EnumWithMemberAttributes.Value1, EnumWithMemberAttributes.Value2, EnumWithMemberAttributes.Value3 ], + [EnumWithMemberAttributes.Value2] = [EnumWithMemberAttributes.Value2 ], + [EnumWithMemberAttributes.Value3] = [EnumWithMemberAttributes.Value3, EnumWithMemberAttributes.Value1 ], + }; + + string json = JsonSerializer.Serialize(value, options); + JsonTestHelper.AssertJsonEqual(""" + { + "CustomValue1": ["CustomValue1", "CustomValue2", "value3"], + "CustomValue2": ["CustomValue2"], + "VALUE3": ["value3", "CustomValue1"] + } + """, json); + } + + [Theory] + [InlineData("\"customvalue1\"")] + [InlineData("\"CUSTOMVALUE1\"")] + [InlineData("\"cUSTOMvALUE1\"")] + [InlineData("\"customvalue2\"")] + [InlineData("\"CUSTOMVALUE2\"")] + [InlineData("\"cUSTOMvALUE2\"")] + public static void EnumWithMemberAttributes_CustomizedValuesAreCaseSensitive(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json, s_optionsWithStringEnumConverter)); + } + + [Theory] + [InlineData("\"value3\"", EnumWithMemberAttributes.Value3)] + [InlineData("\"VALUE3\"", EnumWithMemberAttributes.Value3)] + [InlineData("\"vALUE3\"", EnumWithMemberAttributes.Value3)] + public static void EnumWithMemberAttributes_DefaultValuesAreCaseInsensitive(string json, EnumWithMemberAttributes expectedValue) + { + EnumWithMemberAttributes value = JsonSerializer.Deserialize(json, s_optionsWithStringEnumConverter); + Assert.Equal(expectedValue, value); + } + + public enum EnumWithMemberAttributes + { + [JsonStringEnumMemberName("CustomValue1")] + Value1 = 1, + [JsonStringEnumMemberName("CustomValue2")] + Value2 = 2, + Value3 = 3, + } + + [Theory] + [InlineData(EnumFlagsWithMemberAttributes.Value1, "A")] + [InlineData(EnumFlagsWithMemberAttributes.Value2, "B")] + [InlineData(EnumFlagsWithMemberAttributes.Value3, "C")] + [InlineData(EnumFlagsWithMemberAttributes.Value4, "Value4")] + [InlineData(EnumFlagsWithMemberAttributes.Value1 | EnumFlagsWithMemberAttributes.Value2, "A, B")] + [InlineData(EnumFlagsWithMemberAttributes.Value1 | EnumFlagsWithMemberAttributes.Value2 | EnumFlagsWithMemberAttributes.Value3 | EnumFlagsWithMemberAttributes.Value4, "A, B, C, Value4")] + public static void EnumFlagsWithMemberAttributes_SerializesAsExpected(EnumFlagsWithMemberAttributes value, string expectedJson) + { + string json = JsonSerializer.Serialize(value); + Assert.Equal($"\"{expectedJson}\"", json); + Assert.Equal(value, JsonSerializer.Deserialize(json)); + } + + [Fact] + public static void EnumFlagsWithMemberAttributes_NamingPolicyAndDictionaryKeyPolicy_NotAppliedToCustomNames() + { + JsonSerializerOptions options = new() + { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + DictionaryKeyPolicy = JsonNamingPolicy.KebabCaseUpper, + }; + + Dictionary value = new() + { + [EnumFlagsWithMemberAttributes.Value1] = EnumFlagsWithMemberAttributes.Value1 | EnumFlagsWithMemberAttributes.Value2 | + EnumFlagsWithMemberAttributes.Value3 | EnumFlagsWithMemberAttributes.Value4, + + [EnumFlagsWithMemberAttributes.Value1 | EnumFlagsWithMemberAttributes.Value4] = EnumFlagsWithMemberAttributes.Value3, + [EnumFlagsWithMemberAttributes.Value4] = EnumFlagsWithMemberAttributes.Value2, + }; + + string json = JsonSerializer.Serialize(value, options); + JsonTestHelper.AssertJsonEqual(""" + { + "A": "A, B, C, value4", + "A, VALUE4": "C", + "VALUE4": "B" + } + """, json); + } + + [Theory] + [InlineData("\"a\"")] + [InlineData("\"b\"")] + [InlineData("\"A, b\"")] + [InlineData("\"A, b, C, Value4\"")] + [InlineData("\"A, B, c, Value4\"")] + [InlineData("\"a, b, c, Value4\"")] + [InlineData("\"c, B, A, Value4\"")] + public static void EnumFlagsWithMemberAttributes_CustomizedValuesAreCaseSensitive(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Theory] + [InlineData("\"value4\"", EnumFlagsWithMemberAttributes.Value4)] + [InlineData("\"value4, VALUE4\"", EnumFlagsWithMemberAttributes.Value4)] + [InlineData("\"A, value4, VALUE4, A,B,A,A\"", EnumFlagsWithMemberAttributes.Value1 | EnumFlagsWithMemberAttributes.Value2 | EnumFlagsWithMemberAttributes.Value4)] + [InlineData("\"VALUE4, VAlUE5\"", EnumFlagsWithMemberAttributes.Value4 | EnumFlagsWithMemberAttributes.Value5)] + public static void EnumFlagsWithMemberAttributes_DefaultValuesAreCaseInsensitive(string json, EnumFlagsWithMemberAttributes expectedValue) + { + EnumFlagsWithMemberAttributes value = JsonSerializer.Deserialize(json); + Assert.Equal(expectedValue, value); + } + + [Flags, JsonConverter(typeof(JsonStringEnumConverter))] + public enum EnumFlagsWithMemberAttributes + { + [JsonStringEnumMemberName("A")] + Value1 = 1, + [JsonStringEnumMemberName("B")] + Value2 = 2, + [JsonStringEnumMemberName("C")] + Value3 = 4, + Value4 = 8, + Value5 = 16, + } + + [Theory] + [InlineData(EnumWithConflictingMemberAttributes.Value1)] + [InlineData(EnumWithConflictingMemberAttributes.Value2)] + [InlineData(EnumWithConflictingMemberAttributes.Value3)] + public static void EnumWithConflictingMemberAttributes_IsTolerated(EnumWithConflictingMemberAttributes value) + { + string json = JsonSerializer.Serialize(value); + Assert.Equal("\"Value3\"", json); + Assert.Equal(EnumWithConflictingMemberAttributes.Value1, JsonSerializer.Deserialize(json)); + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum EnumWithConflictingMemberAttributes + { + [JsonStringEnumMemberName("Value3")] + Value1 = 1, + [JsonStringEnumMemberName("Value3")] + Value2 = 2, + Value3 = 3, + } + + [Theory] + [InlineData(EnumWithConflictingCaseNames.ValueWithConflictingCase, "\"ValueWithConflictingCase\"")] + [InlineData(EnumWithConflictingCaseNames.VALUEwithCONFLICTINGcase, "\"VALUEwithCONFLICTINGcase\"")] + [InlineData(EnumWithConflictingCaseNames.Value3, "\"VALUEWITHCONFLICTINGCASE\"")] + public static void EnumWithConflictingCaseNames_SerializesAsExpected(EnumWithConflictingCaseNames value, string expectedJson) + { + string json = JsonSerializer.Serialize(value); + Assert.Equal(expectedJson, json); + EnumWithConflictingCaseNames deserializedValue = JsonSerializer.Deserialize(json); + Assert.Equal(value, deserializedValue); + } + + [Theory] + [InlineData("\"valuewithconflictingcase\"")] + [InlineData("\"vALUEwITHcONFLICTINGcASE\"")] + public static void EnumWithConflictingCaseNames_DeserializingMismatchingCaseDefaultsToFirstValue(string json) + { + EnumWithConflictingCaseNames value = JsonSerializer.Deserialize(json); + Assert.Equal(EnumWithConflictingCaseNames.ValueWithConflictingCase, value); + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum EnumWithConflictingCaseNames + { + ValueWithConflictingCase = 1, + VALUEwithCONFLICTINGcase = 2, + [JsonStringEnumMemberName("VALUEWITHCONFLICTINGCASE")] + Value3 = 3, + } + + [Theory] + [InlineData(EnumWithValidMemberNames.Value1, "\"Intermediate whitespace\\t is allowed\\r\\nin enums\"")] + [InlineData(EnumWithValidMemberNames.Value2, "\"Including support for commas, and other punctuation.\"")] + [InlineData(EnumWithValidMemberNames.Value3, "\"Nice \\uD83D\\uDE80\\uD83D\\uDE80\\uD83D\\uDE80\"")] + [InlineData(EnumWithValidMemberNames.Value4, "\"5\"")] + [InlineData(EnumWithValidMemberNames.Value1 | EnumWithValidMemberNames.Value4, "5")] + public static void EnumWithValidMemberNameOverrides(EnumWithValidMemberNames value, string expectedJsonString) + { + string json = JsonSerializer.Serialize(value); + Assert.Equal(expectedJsonString, json); + Assert.Equal(value, JsonSerializer.Deserialize(json)); + } + + [Fact] + public static void EnumWithNumberIdentifier_CanDeserializeAsUnderlyingValue() + { + EnumWithValidMemberNames value = JsonSerializer.Deserialize("\"4\""); + Assert.Equal(EnumWithValidMemberNames.Value4, value); + } + + [Fact] + public static void EnumWithNumberIdentifier_NoNumberSupported_FailsWhenDeserializingUnderlyingValue() + { + Assert.Throws(() => JsonSerializer.Deserialize("\"4\"", s_optionsWithStringAndNoIntegerEnumConverter)); + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum EnumWithValidMemberNames + { + [JsonStringEnumMemberName("Intermediate whitespace\t is allowed\r\nin enums")] + Value1 = 1, + + [JsonStringEnumMemberName("Including support for commas, and other punctuation.")] + Value2 = 2, + + [JsonStringEnumMemberName("Nice 🚀🚀🚀")] + Value3 = 3, + + [JsonStringEnumMemberName("5")] + Value4 = 4 + } + + [Theory] + [InlineData(EnumFlagsWithValidMemberNames.Value1 | EnumFlagsWithValidMemberNames.Value2, "\"Intermediate whitespace\\t is allowed\\r\\nin enums, Including support for some punctuation; except commas.\"")] + [InlineData(EnumFlagsWithValidMemberNames.Value3 | EnumFlagsWithValidMemberNames.Value4, "\"Nice \\uD83D\\uDE80\\uD83D\\uDE80\\uD83D\\uDE80, 5\"")] + [InlineData(EnumFlagsWithValidMemberNames.Value4, "\"5\"")] + public static void EnumFlagsWithValidMemberNameOverrides(EnumFlagsWithValidMemberNames value, string expectedJsonString) + { + string json = JsonSerializer.Serialize(value); + Assert.Equal(expectedJsonString, json); + Assert.Equal(value, JsonSerializer.Deserialize(json)); + } + + [Theory] + [InlineData("\"\\r\\n Intermediate whitespace\\t is allowed\\r\\nin enums , Including support for some punctuation; except commas.\\r\\n\"", EnumFlagsWithValidMemberNames.Value1 | EnumFlagsWithValidMemberNames.Value2)] + [InlineData("\" 5\\t, \\r\\n 5,\\t 5\"", EnumFlagsWithValidMemberNames.Value4)] + public static void EnumFlagsWithValidMemberNameOverrides_SupportsWhitespaceSeparatedValues(string json, EnumFlagsWithValidMemberNames expectedValue) + { + EnumFlagsWithValidMemberNames result = JsonSerializer.Deserialize(json); + Assert.Equal(expectedValue, result); + } + + [Theory] + [InlineData("\"\"")] + [InlineData("\" \\r\\n \"")] + [InlineData("\",\"")] + [InlineData("\",,,\"")] + [InlineData("\", \\r\\n,,\"")] + [InlineData("\"\\r\\n Intermediate whitespace\\t is allowed\\r\\nin enums , 13 , Including support for some punctuation; except commas.\\r\\n\"")] + [InlineData("\"\\r\\n Intermediate whitespace\\t is allowed\\r\\nin enums , , Including support for some punctuation; except commas.\\r\\n\"")] + [InlineData("\" 5\\t, \\r\\n , UNKNOWN_IDENTIFIER \r\n, 5,\\t 5\"")] + public static void EnumFlagsWithValidMemberNameOverrides_FailsOnInvalidJsonValues(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Flags, JsonConverter(typeof(JsonStringEnumConverter))] + public enum EnumFlagsWithValidMemberNames + { + [JsonStringEnumMemberName("Intermediate whitespace\t is allowed\r\nin enums")] + Value1 = 1, + + [JsonStringEnumMemberName("Including support for some punctuation; except commas.")] + Value2 = 2, + + [JsonStringEnumMemberName("Nice 🚀🚀🚀")] + Value3 = 4, + + [JsonStringEnumMemberName("5")] + Value4 = 8 + } + + [Theory] + [InlineData(typeof(EnumWithInvalidMemberName1), "")] + [InlineData(typeof(EnumWithInvalidMemberName2), "")] + [InlineData(typeof(EnumWithInvalidMemberName3), " ")] + [InlineData(typeof(EnumWithInvalidMemberName4), " HasLeadingWhitespace")] + [InlineData(typeof(EnumWithInvalidMemberName5), "HasTrailingWhitespace\n")] + [InlineData(typeof(EnumWithInvalidMemberName6), "Comma separators not allowed, in flags enums")] + public static void EnumWithInvalidMemberName_Throws(Type enumType, string memberName) + { + object value = Activator.CreateInstance(enumType); + string expectedExceptionMessage = $"Enum type '{enumType.Name}' uses unsupported identifier '{memberName}'."; + InvalidOperationException ex; + + ex = Assert.Throws(() => JsonSerializer.Serialize(value, enumType, s_optionsWithStringEnumConverter)); + Assert.Contains(expectedExceptionMessage, ex.Message); + + ex = Assert.Throws(() => JsonSerializer.Deserialize("\"str\"", enumType, s_optionsWithStringEnumConverter)); + Assert.Contains(expectedExceptionMessage, ex.Message); + } + + public enum EnumWithInvalidMemberName1 + { + [JsonStringEnumMemberName(null!)] + Value + } + + public enum EnumWithInvalidMemberName2 + { + [JsonStringEnumMemberName("")] + Value + } + + public enum EnumWithInvalidMemberName3 + { + [JsonStringEnumMemberName(" ")] + Value + } + + public enum EnumWithInvalidMemberName4 + { + [JsonStringEnumMemberName(" HasLeadingWhitespace")] + Value + } + + public enum EnumWithInvalidMemberName5 + { + [JsonStringEnumMemberName("HasTrailingWhitespace\n")] + Value + } + + [Flags] + public enum EnumWithInvalidMemberName6 + { + [JsonStringEnumMemberName("Comma separators not allowed, in flags enums")] + Value + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/TrimmingTests/EnumConverterTest.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/TrimmingTests/EnumConverterTest.cs index 96c5986749a8a..290c108e1d80b 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/TrimmingTests/EnumConverterTest.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/TrimmingTests/EnumConverterTest.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization; namespace SerializerTrimmingTest { @@ -16,7 +17,18 @@ internal class Program static int Main(string[] args) { string json = JsonSerializer.Serialize(new ClassWithDay()); - return json == @"{""Day"":0}" ? 100 : -1; + if (json != """{"Day":0}""") + { + return -1; + } + + json = JsonSerializer.Serialize(new ClassWithDaySourceGen(), Context.Default.ClassWithDaySourceGen); + if (json != """{"Day":"Sun"}""") + { + return -2; + } + + return 100; } } @@ -24,4 +36,31 @@ internal class ClassWithDay { public DayOfWeek Day { get; set; } } + + internal class ClassWithDaySourceGen + { + [JsonConverter(typeof(JsonStringEnumConverter))] + public DayOfWeek Day { get; set; } + } + + internal enum DayOfWeek + { + [JsonStringEnumMemberName("Sun")] + Sunday, + [JsonStringEnumMemberName("Mon")] + Monday, + [JsonStringEnumMemberName("Tue")] + Tuesday, + [JsonStringEnumMemberName("Wed")] + Wednesday, + [JsonStringEnumMemberName("Thu")] + Thursday, + [JsonStringEnumMemberName("Fri")] + Friday, + [JsonStringEnumMemberName("Sat")] + Saturday + } + + [JsonSerializable(typeof(ClassWithDaySourceGen))] + internal partial class Context : JsonSerializerContext; }