Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Json.Net DeserializeObject failing with OData.Delta - integers only

This problem is affecting my ASP.Net WebApi Patch method which looks a lot like this:

public MyModel Patch(int id, [FromBody]Delta<MyModel> newRecord){/*stuff here*/}

But it's not WebApi that's the problem - the failure is between Json.Net and OData.Delta.

The problem is JsonConvert.DeserializeObject does not see integers of OData.Delta objects and I'm wondering if there's a workaround or fix I can apply.

UPDATE: Have written code (see right down below) in the Json.Net library that will fix this. Just need it to be included in the next update (if James Newton-King allows it)

UPDATE 2: After further testing, I've decided the best course of action is to stop using OData.Delta and write my own (see answer)

Unit tests to prove the problem exists (using statements moved below for clarity)

Test 1: Fails with an int (Int32):

class TestObjWithInt
{
    public int Int { get; set; }
}
[TestMethod]
public void IsApplied_When_IntIsDeserializedToDelta()
{
    string testData = "{\"Int\":1}";
    var deserializedDelta = JsonConvert.DeserializeObject<Delta<TestObjWithInt>>(testData);
    var result = deserializedDelta.GetChangedPropertyNames().Contains("Int");
    Assert.IsTrue(result);
}

Test 2: Succeeds with a long (Int64)

class TestObjWithLong
{
    public long Long { get; set; }
}
[TestMethod]
public void IsApplied_When_LongIsDeserializedToDelta()
{
    string testData = "{\"Long\":1}";
    var deserializedDelta = JsonConvert.DeserializeObject<Delta<TestObjWithLong>>(testData);
    var result = deserializedDelta.GetChangedPropertyNames().Contains("Long");
    Assert.IsTrue(result);
}

And just to be sure that deserialization works to begin with, these two tests both pass.

[TestMethod]
public void IsApplied_When_LongIsDeserializedToTestObject()
{
    string testData = "{\"Long\":1}";
    var deserializedObject = JsonConvert.DeserializeObject<TestObjWithLong>(testData);
    var result = deserializedObject.Long == 1;
    Assert.IsTrue(result);
}
[TestMethod]
public void IsApplied_When_IntIsDeserializedToTestObject()
{
    string testData = "{\"Int\":1}";
    var deserializedObject = JsonConvert.DeserializeObject<TestObjWithInt>(testData);
    var result = deserializedObject.Int == 1;
    Assert.IsTrue(result);
}

I found this OData bug report which sounds like a similar issue but its old and closed so probably not.

Any help would be great.

Using statements (from the top of the test file):

using System;
using System.Linq;
using System.Web.Http.OData;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;

Solution if accepted by James Newton-King - change to release 6.0.6. Replace JsonSerializerInternalReader.cs line 1581:

contract.TrySetMember(newObject, memberName, value);

with:

bool done = false;
while (!(done = done || contract.TrySetMember(newObject, memberName, value)))
{
    switch (reader.TokenType)
    {
        case JsonToken.Integer:
            if (value is long && ((long)value) <= Int32.MaxValue && ((long)value) >= Int32.MinValue)
                value = Convert.ToInt32(value);
            //Add else if (...) to cast to other data types here (none additional required to date).
            else
                done = true;
            break;
        default:
            done = true;
            break;
    }
}
like image 629
Rob Avatar asked Nov 21 '14 05:11

Rob


1 Answers

OData.Delta<T> does not work with Json.Net for any number Types other than Int64. The easiest approach is to write a replacement for OData.Delta<T> (which I've done on company time so I can't post it in its entirety sorry) containing methods like this:

private bool TrySetInt32(object value, PropertyInfo propertyInfo, bool isNullable)
{
    var done = false;
    if (value is Int32)
    {
        propertyInfo.SetValue(_obj, value);
        done = true;
    }
    else if (value == null)
    {
        if (isNullable)
        {
            propertyInfo.SetValue(_obj, value);
            done = true;
        }
    }
    else if (value is Int64) //Json.Net - fallback for numbers is an Int64
    {
        var val = (Int64)value;
        if (val <= Int32.MaxValue && val >= Int32.MinValue)
        {
            done = true;
            propertyInfo.SetValue(_obj, Convert.ToInt32(val));
        }
    }
    else
    {
        Int32 val;
        done = Int32.TryParse(value.ToString(), out val);
        if (done)
            propertyInfo.SetValue(_obj, val);
    }
    return done;
}

The class can be a dynamic generic like this:

public sealed class Patchable<T> : DynamicObject where T : class, new()

With a working variable like this:

T _obj = new T();

In the overridden TrySetMember method, we need to check the underlying type of the property using reflection and call the appropriate TrySet... method like this:

if (underlyingType == typeof(Int16))
    done = TrySetInt16(value, propertyInfo, isNullable);
else if (underlyingType == typeof(Int32))
    done = TrySetInt32(value, propertyInfo, isNullable);

If the value is set successfully we can add the property name to a list that we can then use for patching the original record like this:

if (done)
    _changedPropertyNames.Add(propertyInfo.Name);

public void Patch(T objectToPatch)
{
    foreach (var propertyName in _changedPropertyNames)
    {
        var propertyInfo = _obj.GetType().GetProperty(propertyName);
        propertyInfo.SetValue(objectToPatch, propertyInfo.GetValue(_obj));
    }
}

68 unit tests later, it all seems to work pretty well. Here's an example:

class TestObjWithInt32
{
    public Int32 Int32 { get; set; }
    public Int32? SetNullable { get; set; }
    public Int32? UnsetNullable { get; set; }
}
[TestMethod]
public void IsApplied_When_Int32IsDeserializedToPatchable()
{
    string testData = "{\"Int32\":1,\"SetNullable\":1}";
    var deserializedPatchable = JsonConvert.DeserializeObject<Patchable<TestObjWithInt32>>(testData);
    var result = deserializedPatchable.ChangedPropertyNames.Contains("Int32");
    Assert.IsTrue(result);
    var patchedObject = new TestObjWithInt32();
    Assert.AreEqual<Int32>(0, patchedObject.Int32);
    deserializedPatchable.Patch(patchedObject);
    Assert.AreEqual<Int32>(1, patchedObject.Int32);
    Assert.IsNull(patchedObject.UnsetNullable);
    Assert.IsNotNull(patchedObject.SetNullable);
}
like image 127
Rob Avatar answered Oct 29 '22 10:10

Rob