3

I'm using JSON to act as a configuration file, and I want to have a default value for an array. I'd like to have the serialized JSON ignore the array if it equals the DefaultValueAttribute so that if I decide in the 2nd version of the program to change the DefaultValues, the new defaults will be loaded rather than the untouched copy of the original default values.

My issue is that the code works if the array reference doesn't change, but other code in the program is changing the array but keeping the values in it. (The program maintains many clones of the class so this can't be avoided).

Here is the problem shown using the c# interactive:

using System.ComponentModel;
using Newtonsoft.Json;

class A
{
    [DefaultValue(new int[] { 4, 6, 12 })]
    public int[] SomeArray;
}

var serializerSettings = new JsonSerializerSettings
{
    DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate,
};
var a = new A();
JsonConvert.PopulateObject("{}", a, serializerSettings);

Console.WriteLine(JsonConvert.SerializeObject(a, serializerSettings));
// Prints {}

a.SomeArray = new int[] { 4, 6, 12 };
Console.WriteLine(JsonConvert.SerializeObject(a, serializerSettings));
// Prints {"SomeArray":[4,6,12]}

As you can see, the first SerializeObject works, but if the array contents are the same but it's not the same array reference, it writes out the defaults to the json, which I'd like to avoid.

Is there any way I can have Json.net ignore the array in this situation?

1
  • Hmm, just an idea. Instead of using an array directly as default value for the attribute, wrap it in a custom type whose Equals method you override (don't forget to also override GetHashCode) so that it compares the arrays by content. If the serializer compares the attribute value (your custom wrapper) with the actual field value (an array), it should invoke the overridden Equals method and thus make it work. I hope. As said, just an idea; i haven't tested whether it really works or whether there would be any unintended side effects... Commented Jan 6, 2019 at 23:43

1 Answer 1

4

You have a couple of additional issues with your current architecture other than the problem you have identified:

  1. You are ignoring the documented recommendations for DefaultValueAttribute:

    A DefaultValueAttribute will not cause a member to be automatically initialized with the attribute's value. You must set the initial value in your code.

  2. Your current implementation causes all instances of A with default values to share a reference to a single global instance of the int[3] { 4, 6, 12 } array. Since arrays aren't really read-only, this means that modifying one instance of A will modify all other current and future instances of A with default values:

    var serializerSettings = new JsonSerializerSettings
    {
        DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate,
    };
    var a1 = JsonConvert.DeserializeObject<A>("{}", serializerSettings);
    // The following succeeds
    Assert.IsTrue(a1.SomeArray.SequenceEqual(new int[] { 4, 6, 12 }));
    
    // Sime SomeArray is a globally shared pointer, this will modify all current and future instances of A!
    a1.SomeArray[0] = -999;
    
    var a2 = JsonConvert.DeserializeObject<A>("{}", serializerSettings);
    // The following now fails!
    Assert.IsTrue(a2.SomeArray.SequenceEqual(new int[] { 4, 6, 12 }));
    

This simplest way to avoid these problems is not to use DefaultValueHandling for arrays at all, and instead use conditional property serialization:

class A
{
    static readonly int[] SomeArrayDefaultValue = new int[] { 4, 6, 12 };

    // Disable global settings for NullValueHandling and DefaultValueHandling
    [JsonProperty(NullValueHandling = NullValueHandling.Include, DefaultValueHandling = DefaultValueHandling.Include)]
    public int[] SomeArray = (int[])SomeArrayDefaultValue.Clone();

    public bool ShouldSerializeSomeArray()
    {
        return !(SomeArray != null && SomeArray.SequenceEqual(SomeArrayDefaultValue));
    }
}

Demo fiddle #1 here.

If you are determined to use DefaultValueHandling and DefaultValueAttribute for arrays, you will need a custom contract resolver:

public class ArrayDefaultValueContractResolver : DefaultContractResolver
{
    class ArrayDefaultValueProvider : IValueProvider
    {
        readonly IValueProvider baseProvider;
        readonly System.Array defaultValue;

        public ArrayDefaultValueProvider(IValueProvider baseProvider, System.Array defaultValue)
        {
            this.baseProvider = baseProvider;
            this.defaultValue = defaultValue;
        }

        #region IValueProvider Members

        public object GetValue(object target)
        {
            return baseProvider.GetValue(target);
        }

        public void SetValue(object target, object value)
        {
            // Make sure the default value is cloned since arrays are not truly read only.
            if (value != null && object.ReferenceEquals(value, defaultValue))
                value = defaultValue.Clone();
            baseProvider.SetValue(target, value);
        }

        #endregion
    }

    static void AddArrayDefaultHandling<T>(JsonProperty property)
    {
        var defaultValue = (T [])property.DefaultValue;

        // If the default value has length > 0, clone it when setting it back into the object.
        if (defaultValue.Length > 0)
        {
            property.ValueProvider = new ArrayDefaultValueProvider(property.ValueProvider, defaultValue);
        }

        // Add a ShouldSerialize method that checks for memberwise array equality.
        var valueProvider = property.ValueProvider;
        var oldShouldSerialize = property.ShouldSerialize;
        Predicate<object> shouldSerialize = target =>
            {
                var array = (T[])valueProvider.GetValue(target);
                return !(array == null || array.SequenceEqual(defaultValue));
            };
        if (oldShouldSerialize == null)
            property.ShouldSerialize = shouldSerialize;
        else
            property.ShouldSerialize = (target) => shouldSerialize(target) && oldShouldSerialize(target);
    }

    protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
    {
        var property = base.CreateProperty(member, memberSerialization);
        if (property.PropertyType.IsArray && property.DefaultValue != null && property.DefaultValue.GetType() == property.PropertyType
            && property.PropertyType.GetArrayRank() == 1)
        {
            typeof(ArrayDefaultValueContractResolver)
                .GetMethod("AddArrayDefaultHandling", BindingFlags.Static | BindingFlags.NonPublic)
                .MakeGenericMethod(property.PropertyType.GetElementType())
                .Invoke(null, BindingFlags.Static | BindingFlags.NonPublic, null, new [] { property }, null);
        }
        return property;
    }
}

To use it, cache a static instance somewhere for performance, e.g.

static IContractResolver resolver = new ArrayDefaultValueContractResolver();

And use it as JsonSerializerSettings.ContractResolver when serializing:

var serializerSettings = new JsonSerializerSettings
{
    DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate,
    ContractResolver = resolver,
};
var a = new A();
JsonConvert.PopulateObject("{}", a, serializerSettings);

Console.WriteLine(JsonConvert.SerializeObject(a, serializerSettings));
Assert.IsTrue(JsonConvert.SerializeObject(a, serializerSettings) == "{}");

a.SomeArray = new int[] { 4, 6, 12 };
Console.WriteLine(JsonConvert.SerializeObject(a, serializerSettings));
Assert.IsTrue(JsonConvert.SerializeObject(a, serializerSettings) == "{}");

Demo fiddle #2 here.

Notes:

  • The contract resolver is only implemented for arrays of rank 1. You could extend it to multidimensional arrays if required.

  • The contract resolver automatically clones the default value array instance when setting it into a member, to avoid problem #2 mentioned above. If you don't want that you can remove ArrayDefaultValueProvider.

  • It's not really clear that support for array-valued default values is an intended functionality of Json.NET.

Sign up to request clarification or add additional context in comments.

1 Comment

Thanks. I was aware of the cause and the possibility that it wasn't an intended functionality. I'm making a system that inexperienced programmers will be using, so a custom ContractResolver might be the way to go, I just wasn't sure how to go about it without some example like this. Thanks a bunch.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.