From 15255c46fece334c6d38cde9dbf1305218aecd48 Mon Sep 17 00:00:00 2001 From: Steve Harter Date: Wed, 17 Apr 2019 15:52:49 -0700 Subject: [PATCH] Add Json serializer property name related features (dotnet/corefx#36940) Commit migrated from https://github.com/dotnet/corefx/commit/bc8c22291049b2626ec5b102869685fc95fa41cc --- .../System.Text.Json/ref/System.Text.Json.cs | 15 ++ .../System.Text.Json/src/Resources/Strings.resx | 6 + .../System.Text.Json/src/System.Text.Json.csproj | 3 + .../Json/Serialization/JsonCamelCaseNamePolicy.cs | 45 ++++ .../Serialization/JsonClassInfo.AddProperty.cs | 39 +-- .../Text/Json/Serialization/JsonClassInfo.cs | 44 +++- .../Text/Json/Serialization/JsonNamingPolicy.cs | 26 ++ .../Text/Json/Serialization/JsonPropertyInfo.cs | 105 +++++++- .../Serialization/JsonPropertyNameAttribute.cs | 28 ++ .../Text/Json/Serialization/JsonSerializer.Read.cs | 2 +- .../JsonSerializer.Write.HandleEnumerable.cs | 6 +- .../Json/Serialization/JsonSerializerOptions.cs | 70 ++++- .../System/Text/Json/ThrowHelper.Serialization.cs | 15 +- .../tests/Serialization/CamelCaseUnitTests.cs | 54 ++++ .../tests/Serialization/OptionsTests.cs | 8 +- .../tests/Serialization/PropertyNameTests.cs | 288 +++++++++++++++++++++ .../tests/System.Text.Json.Tests.csproj | 2 + 17 files changed, 699 insertions(+), 57 deletions(-) create mode 100644 src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonCamelCaseNamePolicy.cs create mode 100644 src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNamingPolicy.cs create mode 100644 src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyNameAttribute.cs create mode 100644 src/libraries/System.Text.Json/tests/Serialization/CamelCaseUnitTests.cs create mode 100644 src/libraries/System.Text.Json/tests/Serialization/PropertyNameTests.cs 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 b4e1103..c70d8fa 100644 --- a/src/libraries/System.Text.Json/ref/System.Text.Json.cs +++ b/src/libraries/System.Text.Json/ref/System.Text.Json.cs @@ -320,6 +320,18 @@ namespace System.Text.Json.Serialization { public JsonIgnoreAttribute() { } } + public abstract partial class JsonNamingPolicy + { + protected JsonNamingPolicy() { } + public static System.Text.Json.Serialization.JsonNamingPolicy CamelCase { get { throw null; } } + public abstract string ConvertName(string name); + } + [System.AttributeUsageAttribute(System.AttributeTargets.Property, AllowMultiple=false)] + public sealed partial class JsonPropertyNameAttribute : System.Text.Json.Serialization.JsonAttribute + { + public JsonPropertyNameAttribute(string propertyName) { } + public string Name { get { throw null; } set { } } + } public static partial class JsonSerializer { public static object Parse(System.ReadOnlySpan utf8Json, System.Type returnType, System.Text.Json.Serialization.JsonSerializerOptions options = null) { throw null; } @@ -340,9 +352,12 @@ namespace System.Text.Json.Serialization public JsonSerializerOptions() { } public bool AllowTrailingCommas { get { throw null; } set { } } public int DefaultBufferSize { get { throw null; } set { } } + public System.Text.Json.Serialization.JsonNamingPolicy DictionaryKeyPolicy { get { throw null; } set { } } public bool IgnoreNullValues { get { throw null; } set { } } public bool IgnoreReadOnlyProperties { get { throw null; } set { } } public int MaxDepth { get { throw null; } set { } } + public bool PropertyNameCaseInsensitive { get { throw null; } set { } } + public System.Text.Json.Serialization.JsonNamingPolicy PropertyNamingPolicy { get { throw null; } set { } } public System.Text.Json.JsonCommentHandling ReadCommentHandling { get { throw null; } set { } } public bool WriteIndented { get { throw null; } set { } } } diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index eb4d7ec..0cd15e0 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -324,4 +324,10 @@ Serializer options cannot be changed once serialization or deserialization has occurred. + + The property '{0}.{1}' has the same name as a previous property based on naming or casing policies. + + + The property name for '{0}.{1}' cannot be null as a result of naming policies. + \ No newline at end of file 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 9dc525a..b8d9ac1 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -66,14 +66,17 @@ + + + diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonCamelCaseNamePolicy.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonCamelCaseNamePolicy.cs new file mode 100644 index 0000000..c58fe98 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonCamelCaseNamePolicy.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System.Text.Json.Serialization +{ + internal sealed class JsonCamelCaseNamePolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name) || !char.IsUpper(name[0])) + { + return name; + } + + char[] chars = name.ToCharArray(); + + for (int i = 0; i < chars.Length; i++) + { + if (i == 1 && !char.IsUpper(chars[i])) + { + break; + } + + bool hasNext = (i + 1 < chars.Length); + + // Stop when next char is already lowercase. + if (i > 0 && hasNext && !char.IsUpper(chars[i + 1])) + { + // If the next char is a space, lowercase current char before exiting. + if (chars[i + 1] == ' ') + { + chars[i] = char.ToLowerInvariant(chars[i]); + } + + break; + } + + chars[i] = char.ToLowerInvariant(chars[i]); + } + + return new string(chars); + } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs index 8b1af93..4f65a1d 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Buffers; using System.Diagnostics; using System.Reflection; @@ -10,46 +9,21 @@ namespace System.Text.Json.Serialization { internal partial class JsonClassInfo { - private void AddProperty(Type propertyType, PropertyInfo propertyInfo, Type classType, JsonSerializerOptions options) + private JsonPropertyInfo AddProperty(Type propertyType, PropertyInfo propertyInfo, Type classType, JsonSerializerOptions options) { JsonPropertyInfo jsonInfo = CreateProperty(propertyType, propertyType, propertyInfo, classType, options); if (propertyInfo != null) { - string propertyName = propertyInfo.Name; - - // At this point propertyName is valid UTF16, so just call the simple UTF16->UTF8 encoder. - byte[] propertyNameBytes = Encoding.UTF8.GetBytes(propertyName); - jsonInfo._name = propertyNameBytes; - - // Cache the escaped name. - int valueIdx = JsonWriterHelper.NeedsEscaping(propertyNameBytes); - if (valueIdx == -1) - { - jsonInfo._escapedName = propertyNameBytes; - } - else - { - int length = JsonWriterHelper.GetMaxEscapedLength(propertyNameBytes.Length, valueIdx); - - byte[] tempArray = ArrayPool.Shared.Rent(length); - - JsonWriterHelper.EscapeString(propertyNameBytes, tempArray, valueIdx, out int written); - jsonInfo._escapedName = new byte[written]; - tempArray.CopyTo(jsonInfo._escapedName, 0); - - // We clear the array because it is "user data" (although a property name). - new Span(tempArray, 0, written).Clear(); - ArrayPool.Shared.Return(tempArray); - } - - _propertyRefs.Add(new PropertyRef(GetKey(propertyNameBytes), jsonInfo)); + _propertyRefs.Add(new PropertyRef(GetKey(jsonInfo.CompareName), jsonInfo)); } else { // A single property or an IEnumerable _propertyRefs.Add(new PropertyRef(0, jsonInfo)); } + + return jsonInfo; } internal JsonPropertyInfo CreateProperty(Type declaredPropertyType, Type runtimePropertyType, PropertyInfo propertyInfo, Type parentClassType, JsonSerializerOptions options) @@ -95,10 +69,7 @@ namespace System.Text.Json.Serialization } JsonPropertyInfo runtimeProperty = CreateProperty(property.DeclaredPropertyType, runtimePropertyType, property?.PropertyInfo, Type, options); - - runtimeProperty._name = property._name; - runtimeProperty._escapedName = property._escapedName; - // Copy other settings here as they are added as features. + property.CopyRuntimeSettingsTo(runtimeProperty); return runtimeProperty; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs index 621277b..3baecf8 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs @@ -32,8 +32,11 @@ namespace System.Text.Json.Serialization internal void UpdateSortedPropertyCache(ref ReadStackFrame frame) { + // Todo: on classes with many properties (about 50) we need to switch to a hashtable for performance. + // Todo: when using PropertyNameCaseInsensitive we also need to use the hashtable with case-insensitive + // comparison to handle Turkish etc. cultures properly. + // Set the sorted property cache. Overwrite any existing cache which can occur in multi-threaded cases. - // Todo: on classes with many properties (about 50) we need to switch to a hashtable. if (frame.PropertyRefCache != null) { List cache = frame.PropertyRefCache; @@ -83,13 +86,28 @@ namespace System.Text.Json.Serialization // Ignore properties on enumerable. if (ClassType == ClassType.Object) { + var propertyNames = new HashSet(StringComparer.Ordinal); + foreach (PropertyInfo propertyInfo in type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { // For now we only support public getters\setters if (propertyInfo.GetMethod?.IsPublic == true || propertyInfo.SetMethod?.IsPublic == true) { - AddProperty(propertyInfo.PropertyType, propertyInfo, type, options); + JsonPropertyInfo jsonPropertyInfo = AddProperty(propertyInfo.PropertyType, propertyInfo, type, options); + + if (jsonPropertyInfo.NameAsString == null) + { + ThrowHelper.ThrowInvalidOperationException_SerializerPropertyNameNull(this, jsonPropertyInfo); + } + + // If the JsonPropertyNameAttribute or naming policy results in collisions, throw an exception. + if (!propertyNames.Add(jsonPropertyInfo.CompareNameAsString)) + { + ThrowHelper.ThrowInvalidOperationException_SerializerPropertyNameConflict(this, jsonPropertyInfo); + } + + jsonPropertyInfo.ClearUnusedValuesAfterAdd(); } } } @@ -114,8 +132,16 @@ namespace System.Text.Json.Serialization } } - internal JsonPropertyInfo GetProperty(ReadOnlySpan propertyName, ref ReadStackFrame frame) + internal JsonPropertyInfo GetProperty(JsonSerializerOptions options, ReadOnlySpan propertyName, ref ReadStackFrame frame) { + // If we should compare with case-insensitive, normalize to an uppercase format since that is what is cached on the propertyInfo. + if (options.PropertyNameCaseInsensitive) + { + string utf16PropertyName = Encoding.UTF8.GetString(propertyName.ToArray()); + string upper = utf16PropertyName.ToUpperInvariant(); + propertyName = Encoding.UTF8.GetBytes(upper); + } + ulong key = GetKey(propertyName); JsonPropertyInfo info = null; @@ -212,7 +238,7 @@ namespace System.Text.Json.Serialization { if (propertyName.Length <= PropertyNameKeyLength || // We compare the whole name, although we could skip the first 6 bytes (but it's likely not any faster) - propertyName.SequenceEqual((ReadOnlySpan)propertyRef.Info._name)) + propertyName.SequenceEqual((ReadOnlySpan)propertyRef.Info.CompareName)) { info = propertyRef.Info; return true; @@ -238,8 +264,6 @@ namespace System.Text.Json.Serialization private static ulong GetKey(ReadOnlySpan propertyName) { - Debug.Assert(propertyName.Length > 0); - ulong key; int length = propertyName.Length; @@ -264,15 +288,19 @@ namespace System.Text.Json.Serialization key |= (ulong)propertyName[2] << 16; } } - else + else if (length == 1) { key = propertyName[0]; } + else + { + // An empty name is valid. + key = 0; + } // Embed the propertyName length in the last two bytes. key |= (ulong)propertyName.Length << 48; return key; - } public static Type GetElementType(Type propertyType) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNamingPolicy.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNamingPolicy.cs new file mode 100644 index 0000000..ff4a2a3 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonNamingPolicy.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. +// See the LICENSE file in the project root for more information. + +namespace System.Text.Json.Serialization +{ + /// + /// Determines the naming policy used to convert a JSON name to another format, such as a camel-casing format. + /// + public abstract class JsonNamingPolicy + { + protected JsonNamingPolicy() { } + + /// + /// Returns the naming policy for camel-casing. + /// + public static JsonNamingPolicy CamelCase { get; } = new JsonCamelCaseNamePolicy(); + + /// + /// Converts the provided name. + /// + /// The name to convert. + /// The converted name. + public abstract string ConvertName(string name); + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs index 6f9b82f..3624b6f 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Buffers; using System.Collections; using System.Diagnostics; using System.Reflection; @@ -12,14 +13,25 @@ namespace System.Text.Json.Serialization { internal abstract class JsonPropertyInfo { - // Cache the array and enumerable converters so they don't get created for every enumerable property. + // Cache the converters so they don't get created for every enumerable property. private static readonly JsonEnumerableConverter s_jsonArrayConverter = new DefaultArrayConverter(); private static readonly JsonEnumerableConverter s_jsonEnumerableConverter = new DefaultEnumerableConverter(); internal ClassType ClassType; - internal byte[] _name = default; - internal byte[] _escapedName = default; + // The name of the property with any casing policy or the name specified from JsonPropertyNameAttribute. + private byte[] _name { get; set; } + internal ReadOnlySpan Name => _name; + internal string NameAsString { get; private set; } + + // Used to support case-insensitive comparison + private byte[] _compareName { get; set; } + internal ReadOnlySpan CompareName => _compareName; + internal string CompareNameAsString { get; private set; } + + // The escaped name passed to the writer. + internal byte[] _escapedName { get; private set; } + internal ReadOnlySpan EscapedName => _escapedName; internal bool HasGetter { get; set; } internal bool HasSetter { get; set; } @@ -28,7 +40,6 @@ namespace System.Text.Json.Serialization internal bool IgnoreNullValues { get; private set; } - public ReadOnlySpan Name => _name; // todo: to minimize hashtable lookups, cache JsonClassInfo: //public JsonClassInfo ClassInfo; @@ -65,7 +76,7 @@ namespace System.Text.Json.Serialization internal bool IsNullableType { get; private set; } - public PropertyInfo PropertyInfo { get; private set; } + internal PropertyInfo PropertyInfo { get; private set; } internal Type ParentClassType { get; private set; } @@ -76,9 +87,78 @@ namespace System.Text.Json.Serialization internal virtual void GetPolicies(JsonSerializerOptions options) { DetermineSerializationCapabilities(options); + DeterminePropertyName(options); IgnoreNullValues = options.IgnoreNullValues; } + private void DeterminePropertyName(JsonSerializerOptions options) + { + if (PropertyInfo != null) + { + JsonPropertyNameAttribute nameAttribute = GetAttribute(); + if (nameAttribute != null) + { + NameAsString = nameAttribute.Name; + + // This is detected and thrown by caller. + if (NameAsString == null) + { + return; + } + } + else if (options.PropertyNamingPolicy != null) + { + NameAsString = options.PropertyNamingPolicy.ConvertName(PropertyInfo.Name); + + // This is detected and thrown by caller. + if (NameAsString == null) + { + return; + } + } + else + { + NameAsString = PropertyInfo.Name; + } + + // At this point propertyName is valid UTF16, so just call the simple UTF16->UTF8 encoder. + _name = Encoding.UTF8.GetBytes(NameAsString); + + // Set the compare name. + if (options.PropertyNameCaseInsensitive) + { + CompareNameAsString = NameAsString.ToUpperInvariant(); + _compareName = Encoding.UTF8.GetBytes(CompareNameAsString); + } + else + { + CompareNameAsString = NameAsString; + _compareName = _name; + } + + // Cache the escaped name. + int valueIdx = JsonWriterHelper.NeedsEscaping(_name); + if (valueIdx == -1) + { + _escapedName = _name; + } + else + { + int length = JsonWriterHelper.GetMaxEscapedLength(_name.Length, valueIdx); + + byte[] tempArray = ArrayPool.Shared.Rent(length); + + JsonWriterHelper.EscapeString(_name, tempArray, valueIdx, out int written); + _escapedName = new byte[written]; + tempArray.CopyTo(_escapedName, 0); + + // We clear the array because it is "user data" (although a property name). + new Span(tempArray, 0, written).Clear(); + ArrayPool.Shared.Return(tempArray); + } + } + } + private void DetermineSerializationCapabilities(JsonSerializerOptions options) { bool hasIgnoreAttribute = (GetAttribute() != null); @@ -145,6 +225,21 @@ namespace System.Text.Json.Serialization } } + // After the property is added, clear any state not used later. + internal void ClearUnusedValuesAfterAdd() + { + NameAsString = null; + CompareNameAsString = null; + } + + // Copy any settings defined at run-time to the new property. + internal void CopyRuntimeSettingsTo(JsonPropertyInfo other) + { + other._name = _name; + other._compareName = _compareName; + other._escapedName = _escapedName; + } + internal abstract object GetValueAsObject(object obj, JsonSerializerOptions options); internal TAttribute GetAttribute() where TAttribute : Attribute diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyNameAttribute.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyNameAttribute.cs new file mode 100644 index 0000000..e3a5ecb --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyNameAttribute.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System.Text.Json.Serialization +{ + /// + /// Specifies the property name that is present in the JSON when serializing and deserializing. + /// This overrides any naming policy specified by . + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public sealed class JsonPropertyNameAttribute : JsonAttribute + { + /// + /// Initializes a new instance of with the specified property name. + /// + /// The name of the property. + public JsonPropertyNameAttribute(string propertyName) + { + Name = propertyName; + } + + /// + /// The name of the property. + /// + public string Name { get; set; } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs index 0bad2a0..e312045 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs @@ -42,7 +42,7 @@ namespace System.Text.Json.Serialization Debug.Assert(state.Current.JsonClassInfo != default); ReadOnlySpan propertyName = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; - state.Current.JsonPropertyInfo = state.Current.JsonClassInfo.GetProperty(propertyName, ref state.Current); + state.Current.JsonPropertyInfo = state.Current.JsonClassInfo.GetProperty(options, propertyName, ref state.Current); if (state.Current.JsonPropertyInfo == null) { state.Current.JsonPropertyInfo = s_missingProperty; diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs index 6681727..419e379 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs @@ -39,19 +39,19 @@ namespace System.Text.Json.Serialization if (enumerable == null) { // Write a null object or enumerable. - writer.WriteNull(jsonPropertyInfo._name); + writer.WriteNull(jsonPropertyInfo.Name); return true; } state.Current.Enumerator = enumerable.GetEnumerator(); - if (jsonPropertyInfo._name == null) + if (jsonPropertyInfo.Name == null) { writer.WriteStartArray(); } else { - writer.WriteStartArray(jsonPropertyInfo._name); + writer.WriteStartArray(jsonPropertyInfo.Name); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs index f10a0db..34129b5 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs @@ -18,6 +18,8 @@ namespace System.Text.Json.Serialization private readonly ConcurrentDictionary _classes = new ConcurrentDictionary(); private ClassMaterializer _classMaterializerStrategy; + private JsonNamingPolicy _dictionayKeyPolicy; + private JsonNamingPolicy _jsonPropertyNamingPolicy; private JsonCommentHandling _readCommentHandling; private int _defaultBufferSize = BufferSizeDefault; private int _maxDepth; @@ -25,6 +27,7 @@ namespace System.Text.Json.Serialization private bool _haveTypesBeenCreated; private bool _ignoreNullValues; private bool _ignoreReadOnlyProperties; + private bool _propertyNameCaseInsensitive; private bool _writeIndented; /// @@ -34,7 +37,7 @@ namespace System.Text.Json.Serialization /// /// Defines whether an extra comma at the end of a list of JSON values in an object or array - /// is allowed (and ignored) within the JSON payload being read. + /// is allowed (and ignored) within the JSON payload being deserialized. /// By default, it's set to false, and is thrown if a trailing comma is encountered. /// /// @@ -81,6 +84,25 @@ namespace System.Text.Json.Serialization } /// + /// Specifies the policy used to convert a key's name to another format, such as camel-casing. + /// + /// + /// This property can be set to to specify a camel-casing policy. + /// + public JsonNamingPolicy DictionaryKeyPolicy + { + get + { + return _dictionayKeyPolicy; + } + set + { + VerifyMutable(); + _dictionayKeyPolicy = value; + } + } + + /// /// Determines whether null values are ignored during serialization and deserialization. /// The default value is false. /// @@ -122,8 +144,8 @@ namespace System.Text.Json.Serialization } /// - /// Gets or sets the maximum depth allowed when reading or writing JSON, with the default (i.e. 0) indicating a max depth of 64. - /// Reading past this depth will throw a . + /// Gets or sets the maximum depth allowed when serializing or deserializing JSON, with the default (i.e. 0) indicating a max depth of 64. + /// Going past this depth will throw a . /// /// /// Thrown if this property is set after serialization or deserialization has occurred. @@ -142,6 +164,46 @@ namespace System.Text.Json.Serialization } /// + /// Specifies the policy used to convert a property's name on an object to another format, such as camel-casing. + /// The resulting property name is expected to match the JSON payload during deserialization, and + /// will be used when writing the property name during serialization. + /// + /// + /// The policy is not used for properties that have a applied. + /// This property can be set to to specify a camel-casing policy. + /// + public JsonNamingPolicy PropertyNamingPolicy + { + get + { + return _jsonPropertyNamingPolicy; + } + set + { + VerifyMutable(); + _jsonPropertyNamingPolicy = value; + } + } + + /// + /// Determines whether a property's name uses a case-insensitive comparison during deserialization. + /// The default value is false. + /// + /// There is a performance cost associated when the value is true. + public bool PropertyNameCaseInsensitive + { + get + { + return _propertyNameCaseInsensitive; + } + set + { + VerifyMutable(); + _propertyNameCaseInsensitive = value; + } + } + + /// /// Defines how the comments are handled during deserialization. /// By default is thrown if a comment is encountered. /// @@ -164,7 +226,7 @@ namespace System.Text.Json.Serialization /// /// Defines whether JSON should pretty print which includes: /// indenting nested JSON tokens, adding new lines, and adding white space between property names and values. - /// By default, the JSON is written without any extra white space. + /// By default, the JSON is serialized without any extra white space. /// /// /// Thrown if this property is set after serialization or deserialization has occurred. 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 7c9fbab..ac7f1f3 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 @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; @@ -18,7 +19,7 @@ namespace System.Text.Json [MethodImpl(MethodImplOptions.NoInlining)] public static void ThrowJsonReaderException_DeserializeUnableToConvertValue(Type propertyType, in Utf8JsonReader reader, in ReadStack state) { - throw new JsonReaderException(SR.Format(SR.DeserializeUnableToConvertValue, state.PropertyPath, propertyType), reader.CurrentState); + throw new JsonReaderException(SR.Format(SR.DeserializeUnableToConvertValue, state.PropertyPath, propertyType.FullName), reader.CurrentState); } [MethodImpl(MethodImplOptions.NoInlining)] @@ -38,5 +39,17 @@ namespace System.Text.Json { throw new InvalidOperationException(SR.SerializerOptionsImmutable); } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowInvalidOperationException_SerializerPropertyNameConflict(JsonClassInfo jsonClassInfo, JsonPropertyInfo jsonPropertyInfo) + { + throw new InvalidOperationException(SR.Format(SR.SerializerPropertyNameConflict, jsonClassInfo.Type.FullName, jsonPropertyInfo.PropertyInfo.Name)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowInvalidOperationException_SerializerPropertyNameNull(JsonClassInfo jsonClassInfo, JsonPropertyInfo jsonPropertyInfo) + { + throw new InvalidOperationException(SR.Format(SR.SerializerPropertyNameNull, jsonClassInfo.Type.FullName, jsonPropertyInfo.PropertyInfo.Name)); + } } } diff --git a/src/libraries/System.Text.Json/tests/Serialization/CamelCaseUnitTests.cs b/src/libraries/System.Text.Json/tests/Serialization/CamelCaseUnitTests.cs new file mode 100644 index 0000000..ed6a6d9 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/Serialization/CamelCaseUnitTests.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Reflection; +using Xunit; + +namespace System.Text.Json.Serialization.Tests +{ + public static class CamelCaseUnitTests + { + [Fact] + public static void ToCamelCaseTest() + { + // These test cases were copied from Json.NET. + Assert.Equal("urlValue", ConvertToCamelCase("URLValue")); + Assert.Equal("url", ConvertToCamelCase("URL")); + Assert.Equal("id", ConvertToCamelCase("ID")); + Assert.Equal("i", ConvertToCamelCase("I")); + Assert.Equal("", ConvertToCamelCase("")); + Assert.Equal(null, ConvertToCamelCase(null)); + Assert.Equal("person", ConvertToCamelCase("Person")); + Assert.Equal("iPhone", ConvertToCamelCase("iPhone")); + Assert.Equal("iPhone", ConvertToCamelCase("IPhone")); + Assert.Equal("i Phone", ConvertToCamelCase("I Phone")); + Assert.Equal("i Phone", ConvertToCamelCase("I Phone")); + Assert.Equal(" IPhone", ConvertToCamelCase(" IPhone")); + Assert.Equal(" IPhone ", ConvertToCamelCase(" IPhone ")); + Assert.Equal("isCIA", ConvertToCamelCase("IsCIA")); + Assert.Equal("vmQ", ConvertToCamelCase("VmQ")); + Assert.Equal("xml2Json", ConvertToCamelCase("Xml2Json")); + Assert.Equal("snAkEcAsE", ConvertToCamelCase("SnAkEcAsE")); + Assert.Equal("snA__kEcAsE", ConvertToCamelCase("SnA__kEcAsE")); + Assert.Equal("snA__ kEcAsE", ConvertToCamelCase("SnA__ kEcAsE")); + Assert.Equal("already_snake_case_ ", ConvertToCamelCase("already_snake_case_ ")); + Assert.Equal("isJSONProperty", ConvertToCamelCase("IsJSONProperty")); + Assert.Equal("shoutinG_CASE", ConvertToCamelCase("SHOUTING_CASE")); + Assert.Equal("9999-12-31T23:59:59.9999999Z", ConvertToCamelCase("9999-12-31T23:59:59.9999999Z")); + Assert.Equal("hi!! This is text. Time to test.", ConvertToCamelCase("Hi!! This is text. Time to test.")); + Assert.Equal("building", ConvertToCamelCase("BUILDING")); + Assert.Equal("building Property", ConvertToCamelCase("BUILDING Property")); + Assert.Equal("building Property", ConvertToCamelCase("Building Property")); + Assert.Equal("building PROPERTY", ConvertToCamelCase("BUILDING PROPERTY")); + } + + // Use a helper method since the method is not public. + private static string ConvertToCamelCase(string name) + { + JsonNamingPolicy policy = JsonNamingPolicy.CamelCase; + string value = policy.ConvertName(name); + return value; + } + } +} diff --git a/src/libraries/System.Text.Json/tests/Serialization/OptionsTests.cs b/src/libraries/System.Text.Json/tests/Serialization/OptionsTests.cs index 35c7d25..be416b0 100644 --- a/src/libraries/System.Text.Json/tests/Serialization/OptionsTests.cs +++ b/src/libraries/System.Text.Json/tests/Serialization/OptionsTests.cs @@ -18,16 +18,22 @@ namespace System.Text.Json.Serialization.Tests // Verify defaults and ensure getters do not throw. Assert.False(options.AllowTrailingCommas); Assert.Equal(16 * 1024, options.DefaultBufferSize); + Assert.Equal(null, options.DictionaryKeyPolicy); Assert.False(options.IgnoreNullValues); Assert.Equal(0, options.MaxDepth); + Assert.Equal(false, options.PropertyNameCaseInsensitive); + Assert.Equal(null, options.PropertyNamingPolicy); Assert.Equal(JsonCommentHandling.Disallow, options.ReadCommentHandling); Assert.False(options.WriteIndented); - // Setters should throw + // Setters should always throw; we don't check to see if the value is the same or not. Assert.Throws(() => options.AllowTrailingCommas = options.AllowTrailingCommas); Assert.Throws(() => options.DefaultBufferSize = options.DefaultBufferSize); + Assert.Throws(() => options.DictionaryKeyPolicy = options.DictionaryKeyPolicy); Assert.Throws(() => options.IgnoreNullValues = options.IgnoreNullValues); Assert.Throws(() => options.MaxDepth = options.MaxDepth); + Assert.Throws(() => options.PropertyNameCaseInsensitive = options.PropertyNameCaseInsensitive); + Assert.Throws(() => options.PropertyNamingPolicy = options.PropertyNamingPolicy); Assert.Throws(() => options.ReadCommentHandling = options.ReadCommentHandling); Assert.Throws(() => options.WriteIndented = options.WriteIndented); } diff --git a/src/libraries/System.Text.Json/tests/Serialization/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/Serialization/PropertyNameTests.cs new file mode 100644 index 0000000..0bb3ba1 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/Serialization/PropertyNameTests.cs @@ -0,0 +1,288 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Xunit; + +namespace System.Text.Json.Serialization.Tests +{ + public static class PropertyNameTests + { + [Fact] + public static void CamelCaseDeserializeNoMatch() + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + SimpleTestClass obj = JsonSerializer.Parse(@"{""MyInt16"":1}", options); + + // This is 0 (default value) because the data does not match the property "MyInt16" that is assuming camel-casing of "myInt16". + Assert.Equal(0, obj.MyInt16); + } + + [Fact] + public static void CamelCaseDeserializeMatch() + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + SimpleTestClass obj = JsonSerializer.Parse(@"{""myInt16"":1}", options); + + // This is 1 because the data matches the property "MyInt16" that is assuming camel-casing of "myInt16". + Assert.Equal(1, obj.MyInt16); + } + + [Fact] + public static void CamelCaseSerialize() + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + SimpleTestClass obj = JsonSerializer.Parse(@"{}", options); + string json = JsonSerializer.ToString(obj, options); + Assert.Contains(@"""myInt16"":0", json); + Assert.Contains(@"""myInt32"":0", json); + } + + [Fact] + public static void CustomNamePolicy() + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = new UppercaseNamingPolicy(); + + SimpleTestClass obj = JsonSerializer.Parse(@"{""MYINT16"":1}", options); + + // This is 1 because the data matches the property "MYINT16" that is uppercase of "myInt16". + Assert.Equal(1, obj.MyInt16); + } + + [Fact] + public static void NullNamePolicy() + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = new NullNamingPolicy(); + + // A policy that returns null is not allowed. + Assert.Throws(() => JsonSerializer.Parse(@"{}", options)); + } + + [Fact] + public static void IgnoreCase() + { + { + // A non-match scenario with no options (case-sensitive by default). + SimpleTestClass obj = JsonSerializer.Parse(@"{""myint16"":1}"); + Assert.Equal(0, obj.MyInt16); + } + + { + // A non-match scenario with default options (case-sensitive by default). + var options = new JsonSerializerOptions(); + SimpleTestClass obj = JsonSerializer.Parse(@"{""myint16"":1}", options); + Assert.Equal(0, obj.MyInt16); + } + + { + var options = new JsonSerializerOptions(); + options.PropertyNameCaseInsensitive = true; + SimpleTestClass obj = JsonSerializer.Parse(@"{""myint16"":1}", options); + Assert.Equal(1, obj.MyInt16); + } + } + + [Fact] + public static void JsonPropertyNameAttribute() + { + { + OverridePropertyNameDesignTime_TestClass obj = JsonSerializer.Parse(@"{""Blah"":1}"); + Assert.Equal(1, obj.myInt); + + obj.myObject = 2; + + string json = JsonSerializer.ToString(obj); + Assert.Contains(@"""Blah"":1", json); + Assert.Contains(@"""BlahObject"":2", json); + } + + // The JsonPropertyNameAttribute should be unaffected by JsonNamingPolicy and PropertyNameCaseInsensitive. + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.PropertyNameCaseInsensitive = true; + + OverridePropertyNameDesignTime_TestClass obj = JsonSerializer.Parse(@"{""Blah"":1}", options); + Assert.Equal(1, obj.myInt); + + string json = JsonSerializer.ToString(obj); + Assert.Contains(@"""Blah"":1", json); + } + } + + [Fact] + public static void JsonNameAttributeDuplicateDesignTimeFail() + { + { + var options = new JsonSerializerOptions(); + Assert.Throws(() => JsonSerializer.Parse("{}", options)); + } + + { + var options = new JsonSerializerOptions(); + Assert.Throws(() => JsonSerializer.ToString(new DuplicatePropertyNameDesignTime_TestClass(), options)); + } + } + + [Fact] + public static void JsonNullNameAttribute() + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.PropertyNameCaseInsensitive = true; + + // A null name in JsonPropertyNameAttribute is not allowed. + Assert.Throws(() => JsonSerializer.ToString(new NullPropertyName_TestClass(), options)); + } + + [Fact] + public static void JsonNameConflictOnCamelCasingFail() + { + { + // Baseline comparison - no options set. + IntPropertyNamesDifferentByCaseOnly_TestClass obj = JsonSerializer.Parse("{}"); + JsonSerializer.ToString(obj); + } + + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + Assert.Throws(() => JsonSerializer.Parse("{}", options)); + Assert.Throws(() => JsonSerializer.ToString(new IntPropertyNamesDifferentByCaseOnly_TestClass(), options)); + } + + { + // Baseline comparison - no options set. + ObjectPropertyNamesDifferentByCaseOnly_TestClass obj = JsonSerializer.Parse("{}"); + JsonSerializer.ToString(obj); + } + + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + Assert.Throws(() => JsonSerializer.Parse("{}", options)); + Assert.Throws(() => JsonSerializer.ToString(new ObjectPropertyNamesDifferentByCaseOnly_TestClass(), options)); + } + } + + [Fact] + public static void JsonNameConflictOnCaseInsensitiveFail() + { + string json = @"{""myInt"":1,""MyInt"":2}"; + + { + var options = new JsonSerializerOptions(); + options.PropertyNameCaseInsensitive = true; + + Assert.Throws(() => JsonSerializer.Parse(json, options)); + Assert.Throws(() => JsonSerializer.ToString(new IntPropertyNamesDifferentByCaseOnly_TestClass(), options)); + } + } + + [Fact] + public static void JsonOutputNotAffectedByCasingPolicy() + { + { + // Baseline. + string json = JsonSerializer.ToString(new SimpleTestClass()); + Assert.Contains(@"""MyInt16"":0", json); + } + + // The JSON output should be unaffected by PropertyNameCaseInsensitive. + { + var options = new JsonSerializerOptions(); + options.PropertyNameCaseInsensitive = true; + + string json = JsonSerializer.ToString(new SimpleTestClass(), options); + Assert.Contains(@"""MyInt16"":0", json); + } + } + + [Fact] + public static void EmptyPropertyName() + { + string json = @"{"""":1}"; + + { + var obj = new EmptyPropertyName_TestClass(); + obj.MyInt1 = 1; + + string jsonOut = JsonSerializer.ToString(obj); + Assert.Equal(json, jsonOut); + } + + { + EmptyPropertyName_TestClass obj = JsonSerializer.Parse(json); + Assert.Equal(1, obj.MyInt1); + } + } + } + + public class OverridePropertyNameDesignTime_TestClass + { + [JsonPropertyName("Blah")] + public int myInt { get; set; } + + [JsonPropertyName("BlahObject")] + public object myObject { get; set; } + } + + public class DuplicatePropertyNameDesignTime_TestClass + { + [JsonPropertyName("Blah")] + public int MyInt1 { get; set; } + + [JsonPropertyName("Blah")] + public int MyInt2 { get; set; } + } + + public class EmptyPropertyName_TestClass + { + [JsonPropertyName("")] + public int MyInt1 { get; set; } + } + + public class NullPropertyName_TestClass + { + [JsonPropertyName(null)] + public int MyInt1 { get; set; } + } + + public class IntPropertyNamesDifferentByCaseOnly_TestClass + { + public int myInt { get; set; } + public int MyInt { get; set; } + } + + public class ObjectPropertyNamesDifferentByCaseOnly_TestClass + { + public int myObject { get; set; } + public int MyObject { get; set; } + } + + public class UppercaseNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + return name.ToUpperInvariant(); + } + } + + public class NullNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + return null; + } + } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj index fb55335..42f3f71 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj @@ -26,6 +26,7 @@ + @@ -34,6 +35,7 @@ + -- 2.7.4