3

Using Json.NET, I'd like to map a JObject onto a .NET object with the following behavior:

  1. For (non-null) array properties of the JObject, replace the entire collection on the target object.

  2. For (non-null) object properties of the JObject, reuse the target object's property if it's not null, and map just the provided properties onto it.

JsonSerializer.Populate seems to be what I want, as described in this answer. As for the behaviors I'm looking for, it seems I can achieve one or the other, but not both, via JsonSerializerSettings.ObjectCreationHandling. ObjectCreationHandling.Replace does what I want with respect to requirement #1, while ObjectCreationHandling.Auto does what I want with respect to requirement #2, but it appends array items onto the existing collection.

What is the recommended way to achieve both requirements here?

Community
  • 1
  • 1
Todd Menier
  • 37,557
  • 17
  • 150
  • 173
  • 1
    It should be noted that JObject.Merge provides a great way to configure array behavior via [MergeArrayHandling](http://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_Linq_MergeArrayHandling.htm). But this is for JObject-to-JObject mapping, not JObject-to-.NET object. It's unfortunate that this setting isn't available in JsonSerializerSettings. – Todd Menier Feb 10 '17 at 17:54

3 Answers3

5

Json.NET will automatically replace any arrays or read-only collections. To clear out read-write collections when deserializing, you could create a custom contract resolver that adds an OnDeserializingCallback to every modifiable collection that clears the collection when deserialization begins. Clearing the collection rather that replacing it outright handles cases where the collection is get-only, for instance:

public class RootObject
{
    readonly HashSet<int> hashSet = new HashSet<int>();
    public HashSet<int> HashSetValues { get { return this.hashSet; } }
}

The contract resolver is as follows:

public class CollectionClearingContractResolver : DefaultContractResolver
{
    static void ClearGenericCollectionCallback<T>(object o, StreamingContext c)
    {
        var collection = o as ICollection<T>;
        if (collection == null || collection is Array || collection.IsReadOnly)
            return;
        collection.Clear();
    }

    static SerializationCallback ClearListCallback = (o, c) =>
        {
            var collection = o as IList;
            if (collection == null || collection is Array || collection.IsReadOnly)
                return;
            collection.Clear();
        };

    protected override JsonArrayContract CreateArrayContract(Type objectType)
    {
        var contract = base.CreateArrayContract(objectType);
        if (!objectType.IsArray)
        {
            if (typeof(IList).IsAssignableFrom(objectType))
            {
                contract.OnDeserializingCallbacks.Add(ClearListCallback);
            }
            else if (objectType.GetCollectItemTypes().Count() == 1)
            {
                MethodInfo method = typeof(CollectionClearingContractResolver).GetMethod("ClearGenericCollectionCallback", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
                MethodInfo generic = method.MakeGenericMethod(contract.CollectionItemType);
                contract.OnDeserializingCallbacks.Add((SerializationCallback)Delegate.CreateDelegate(typeof(SerializationCallback), generic));
            }
        }

        return contract;
    }
}

public static class TypeExtensions
{
    public static IEnumerable<Type> GetInterfacesAndSelf(this Type type)
    {
        if (type == null)
            throw new ArgumentNullException();
        if (type.IsInterface)
            return new[] { type }.Concat(type.GetInterfaces());
        else
            return type.GetInterfaces();
    }

    public static IEnumerable<Type> GetCollectItemTypes(this Type type)
    {
        foreach (Type intType in type.GetInterfacesAndSelf())
        {
            if (intType.IsGenericType
                && intType.GetGenericTypeDefinition() == typeof(ICollection<>))
            {
                yield return intType.GetGenericArguments()[0];
            }
        }
    }
}

public static class JsonExtensions
{
    public static void Populate<T>(this JToken value, T target) where T : class
    {
        value.Populate(target, null);
    }

    public static void Populate<T>(this JToken value, T target, JsonSerializerSettings settings) where T : class
    {
        using (var sr = value.CreateReader())
        {
            JsonSerializer.CreateDefault(settings).Populate(sr, target);
        }
    }
}

Then to use it, do:

var settings = new JsonSerializerSettings
{
    ContractResolver = new CollectionClearingContractResolver(),
};
jObject.Populate(rootObject, settings);

Sample fiddle.

Such a contract resolver would also be useful when deserializing objects that populate collections in their default constructor, as in Deserialization causes copies of List-Entries.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • Thanks for the thorough answer. Probably has efficiency gains over what I did, albeit the reflection etc. makes for a more complex solution. Sill weighing my options, but thanks again. – Todd Menier Feb 10 '17 at 20:40
3

One fix is to use a custom JsonConverter that effectively replaces collections by ignoring the existing value when a collection type is detected.

public class ReplaceArrayConverter : JsonConverter
{
    public override bool CanConvert(Type objectType) {
        // check for Array, IList, etc.
        return objectType.IsCollection(); 
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) {
        // ignore existingValue and just create a new collection
        return JsonSerializer.CreateDefault().Deserialize(reader, objectType);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) {
        JsonSerializer.CreateDefault().Serialize(writer, value);
    }
}

Used like so:

var ser = JsonSerializer.CreateDefault(new JsonSerializerSettings {
    Converters = new[] { new ReplaceArrayConverter() }
});

using (var reader = jObj.CreateReader()) {
    ser.Populate(reader, model);
}
Todd Menier
  • 37,557
  • 17
  • 150
  • 173
  • 1
    Actually all you need to do is to ignore the `existingValue` and return a new collection. – dbc Feb 10 '17 at 19:17
  • @dbc I noticed that calling `serializer.Deserialize(reader, objectType)` in `ReadJson` gets me in an infinite loop. Pretty easy to see why. Do you think using `JsonSerializer.CreateDefault()` is the right approach there? It works, but not sure if there's something simpler/more efficient. – Todd Menier Feb 10 '17 at 20:25
  • That's one of the reasons I suggested using a contract resolver instead. You could also disable the converter for recursive calls along the lines of `WriteJson()` from [this answer](http://stackoverflow.com/a/30179162/3744182). Doing it that way preserves your other serializer settings. – dbc Feb 10 '17 at 20:34
0

Before calling PopulateObject, try setting the collection equal to null.

Below is an example using a simple C# console app. If you put a breakpoint at the end of the Main method, you'll see that the original Id property stays the same (since it isn't set in objectNew), while the Name and SomeList properties are updated to new values. You'll also notice that SomeList contains 4,5,6 instead of 1,2,3 since it is nulled first before calling PopulateObject.

using Newtonsoft.Json;

class Program
{
    public static void Main()
    {
        MyObject objectOriginal = new()
        {
            Id = 0,
            Name = "Original",
            SomeList = new List<int> { 1,2,3}
        };

        string jsonString = "{\"name\": \"New\",\"someList\": [4,5,6]}";

        MyObject? objectNew = JsonConvert.DeserializeObject<MyObject>(jsonString);

        if (objectNew?.SomeList != null)
        {
            objectOriginal.SomeList = null;
        }

        JsonConvert.PopulateObject(jsonString, objectOriginal);
    }

}

class MyObject
{
    [JsonProperty("id")]
    public int? Id { get; set; }

    [JsonProperty("name")]
    public string? Name { get; set; }

    [JsonProperty("someList")]
    public List<int>? SomeList { get; set; }

    public MyObject() { }
}
Sam
  • 1
  • 1