Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why won't Delta.Patch update a byte array to a null value?

Tags:

c#

.net

odata

My object model has a byte array for storing an image. When I try to update this byte array to a new value via Delta.Patch, it works just fine, but when I try to update it to null, it fails.

This is the line of code I'm looking at is

update.Patch(entity);

If I look at the update object, I can see that update.ChangedProperties includes my image property, so it knows it's aware that it should be updated. I can also see that update._instance contains the instance of the object which has a null value for the image field, and I can use Fiddler to see that the changed value is being sent up as null.

But when I look at the entity object after calling .Patch when the new value is supposed to be null, the byte array is not updated. Other updates in the Delta get updated correctly, but not this byte array.

What could be causing this? I'm still new to OData, and am not sure if I'm missing something obvious here.

like image 929
Rachel Avatar asked Dec 06 '25 20:12

Rachel


1 Answers

I reviewed source code of OData(WebAPI version) and (probably) found core issue. Issue also apply to ASP.NET Core version as it is shared code base with ASP.NET WebAPI.

Issue

You call Patch(TStructuralType original) method which calls CopyChangedValues(TStructuralType original) method. Both are public members of Delta<T> class

public void Patch(TStructuralType original)
{
    CopyChangedValues(original);
}

Inside CopyChangedValues(TStructuralType original) method is a piece of code that handles copying values to original instance. Code iterates through PropertyAccessor<TStructuralType> array and calls Copy(TStructuralType from, TStructuralType to) method.

// For regular non-structural properties at current level.
PropertyAccessor<TStructuralType>[] propertiesToCopy =
                this._changedProperties.Select(s => _allProperties[s]).ToArray();
foreach (PropertyAccessor<TStructuralType> propertyToCopy in propertiesToCopy)
{
    propertyToCopy.Copy(_instance, original);
}

Inside Copy(TStructuralType from, TStructuralType to) implemented in PropertyAccessor<TStructuralType> you'll find call to abstract SetValue(TStructuralType instance, object value).

public void Copy(TStructuralType from, TStructuralType to)
{
    if (from == null)
    {
        throw Error.ArgumentNull("from");
    }
    if (to == null)
    {
        throw Error.ArgumentNull("to");
    }
    SetValue(to, GetValue(from));
}

This method is implemented by FastPropertyAccessor<TStructuralType> class.

public override void SetValue(TStructuralType instance, object value)
{
    if (instance == null)
    {
        throw Error.ArgumentNull("instance");
    }

    if (_isCollection)
    {
        DeserializationHelpers.SetCollectionProperty(instance, _property.Name, edmPropertyType: null,
            value: value, clearCollection: true);
    }
    else
    {
        _setter(instance, value);
    }
}

Important line of code is if (_isCollection). This boolean flag is set in the constructor and calls IsCollection() static method in TypeHelper class.

public FastPropertyAccessor(PropertyInfo property)
    : base(property)
{
    _property = property;
    _isCollection = TypeHelper.IsCollection(property.PropertyType);

    if (!_isCollection)
    {
        _setter = PropertyHelper.MakeFastPropertySetter<TStructuralType>(property);
    }
    _getter = PropertyHelper.MakeFastPropertyGetter(property);
}

In IsCollection(Type clrType) we traverse call to IsCollection(this Type type, out Type elementType).

public static bool IsCollection(Type clrType)
{
    Type elementType;
    return TypeHelper.IsCollection(clrType, out elementType);
}

Here are important lines following // see if this type should be ignored. comment(which is weird one and may indicate someone forgot to finish what he has started) where only string(char[]) are excluded. Other arrays (including byte[]) skips to following code, that evaluates byte[](and any other array type) positively as those types are implementing IEnumerable<T> interface.

public static bool IsCollection(Type clrType, out Type elementType)
{
    if (clrType == null)
    {
        throw Error.ArgumentNull("clrType");
    }

    elementType = clrType;

    // see if this type should be ignored.
    if (clrType == typeof(string))
    {
        return false;
    }

    Type collectionInterface
        = clrType.GetInterfaces()
            .Union(new[] { clrType })
            .FirstOrDefault(
                t => TypeHelper.IsGenericType(t)
                        && t.GetGenericTypeDefinition() == typeof(IEnumerable<>));

    if (collectionInterface != null)
    {
        elementType = collectionInterface.GetGenericArguments().Single();
        return true;
    }

    return false;
}

If we jump back to SetValue(TEntityType entity, object value) method implementation we end up calling DeserializationHelpers.SetCollectionProperty(entity, _property.Name, edmPropertyType: null, value: value, clearCollection: true); in DeserializationHelpers class.

if (_isCollection)
{
    DeserializationHelpers.SetCollectionProperty(instance, _property.Name, edmPropertyType: null,
        value: value, clearCollection: true);
}

It is clear that implementation of this method is very defensive and avoids throwing exception in case value of collection is null. First line of the method is if (value != null) and there is no else block or code after the code block to be executed. We can literally say, null values are ignored for every type implementing IEnumerable<T>, thus not set.

internal static void SetCollectionProperty(object resource, string propertyName,
    IEdmCollectionTypeReference edmPropertyType, object value, bool clearCollection)
{
    if (value != null)
    {
        IEnumerable collection = value as IEnumerable;
        Contract.Assert(collection != null,
            "SetCollectionProperty is always passed the result of ODataFeedDeserializer or ODataCollectionDeserializer");

        Type resourceType = resource.GetType();
        Type propertyType = GetPropertyType(resource, propertyName);

        Type elementType;
        if (!TypeHelper.IsCollection(propertyType, out elementType))
        {
            string message = Error.Format(SRResources.PropertyIsNotCollection, propertyType.FullName, propertyName, resourceType.FullName);
            throw new SerializationException(message);
        }

        IEnumerable newCollection;
        if (CanSetProperty(resource, propertyName) &&
            CollectionDeserializationHelpers.TryCreateInstance(propertyType, edmPropertyType, elementType, out newCollection))
        {
            // settable collections
            collection.AddToCollection(newCollection, elementType, resourceType, propertyName, propertyType);
            if (propertyType.IsArray)
            {
                newCollection = CollectionDeserializationHelpers.ToArray(newCollection, elementType);
            }

            SetProperty(resource, propertyName, newCollection);
        }
        else
        {
            // get-only collections.
            newCollection = GetProperty(resource, propertyName) as IEnumerable;
            if (newCollection == null)
            {
                string message = Error.Format(SRResources.CannotAddToNullCollection, propertyName, resourceType.FullName);
                throw new SerializationException(message);
            }

            if (clearCollection)
            {
                newCollection.Clear(propertyName, resourceType);
            }

            collection.AddToCollection(newCollection, elementType, resourceType, propertyName, propertyType);
        }
    }
}

Solution 1

First possible solution is to create custom model binder and handle null values for byte[] returning empty byte array and adding NullByteArrayModelBinder class to model binders.

Disclaimer: Didn't tested it, but should work.

public class NullByteArrayModelBinder : DefaultModelBinder {
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) {
        if(bindingContext.ModelType == typeof(byte[])) {
            return base.BindModel(controllerContext, bindingContext) ?? new byte[0];
        }

        return base.BindModel(controllerContext, bindingContext);
    }
}

This approach have one downside. Consumers of the OData also need to handle empty array array.Length > 0 everywhere where array != null check takes place now.


Solution 2

Second option is to customize serialization and deserialization.

Serialize: from empty array to null => array.Length > 0 ? array : null;

Deserialize: from null to empty array=> array ?? new byte[0];

Hope it helps!

like image 87
dropoutcoder Avatar answered Dec 09 '25 10:12

dropoutcoder