6

I am attempting to serialize a set of objects that contain a variety of types including object references to other custom types. I want these object references to be excluded when it's property members are all either a default value or null. Here is the setup:

public class ObjectA
{
    [DefaultValue(2)]
    [JsonProperty(PropertyName = "propertyA")]
    public int PropertyA { get; set; } = 6;

    [JsonProperty(PropertyName = "objectB")]
    public ObjectB ObjectB { get; set; } = new ObjectB();
}

public class ObjectB
{
    [DefaultValue(2)]
    [JsonProperty(PropertyName = "propertyA")]
    public int PropertyA { get; set; } = 2;

    [JsonProperty(PropertyName = "propertyB")]
    public string PropertyB { get; set; }
}

The problem is that when I serialize ObjectA using the following:

var settings = new JsonSerializerSettings();

settings.NullValueHandling = NullValueHandling.Ignore;
settings.DefaultValueHandling = DefaultValueHandling.Ignore;

return JsonConvert.SerializeObject(ObjectA, settings);

I want to see this:

{
    "propertyA": 6
}

However I am still seeing an empty object property reference:

{
    "propertyA": 6,
    "objectB" : {}
}

I want to get rid of the objectB in the json and only have it show up if one of it's members is not a default value or null. While this example only shows one level of nesting, it needs to hold true for any level of object nesting.

Jamie
  • 123
  • 2
  • 9
  • Are you sure you don't want to see this - `{"propertyA":6,"objectB":{"propertyA":2}}`, because `ObjectB` too has a not null property just as `PropertyA` in `ObjectA`... – gkb Apr 12 '17 at 05:15
  • The DefaultValueHandling.Ignore should exclude PropertyA in ObjectB from the result. – Jamie Apr 12 '17 at 05:17
  • Shouldn't it also ignore PropertyA in objectA? – gkb Apr 12 '17 at 05:21
  • PropertyA in objectA is initialized with a value of 6 while the DefaultValue annotation is set to 2. It would not be ignored. – Jamie Apr 12 '17 at 05:23
  • Ohh now I see what you meant by default..thanks... – gkb Apr 12 '17 at 05:46
  • Brian's solution looks better, will test against my req's and report finding to confirm as dup. – Jamie Apr 14 '17 at 05:17
  • Brian's solution is working for my requirements, it looks cleaner and I think is a more viable solution for my issue. Just need to figure out how to dupe this question. – Jamie Apr 14 '17 at 05:25

2 Answers2

3

The problem is with the Default value of the ObjectB itself as an object and not with the default values of the properties it has when you are serializing ObjectA.

If you go through the example here, they have mentioned the expected defaults for nullable types and that for objects which is null

This option will ignore all default values (e.g. null for objects and nullable types; 0 for integers, decimals and floating point numbers; and false for booleans).

To illustrate what it means, try serializing ObjectB with defaults -

var objectB = new ObjectB
{
    PropertyA = 2 //The default value is also 2.
};


string serialized = JsonConvert.SerializeObject(objectB, 
                            Newtonsoft.Json.Formatting.Indented, 
                            new JsonSerializerSettings {                                
                                DefaultValueHandling = DefaultValueHandling.Ignore
                            });

And what you get is {}.

Now if you explicitly set ObjectB to null, only then the serializer will ignore it as an object on the whole -

var objectA = new ObjectA
{
    PropertyA = 6,
    ObjectB = null
};
string serialized = JsonConvert.SerializeObject(objectA, 
                            Newtonsoft.Json.Formatting.Indented, 
                            new JsonSerializerSettings {                                
                                DefaultValueHandling = DefaultValueHandling.Ignore
                            });

And you will get the expected result as -

{
  "propertyA": 6
}

You can try with different values and see if this satisfies your expectation.

gkb
  • 1,449
  • 2
  • 15
  • 30
  • Your example is correct, however I need the objects instantiated from the beginning. The scenario I will be using this in is options/settings classes that will be used to configure an application. The developer experience will be better if they don't have to new up every settings area they want to configure. – Jamie Apr 13 '17 at 05:37
1

So I have worked out an ugly solution that works to recursively reduce Json nodes that are empty while maintaining nested objects that are instantiated by default. The solution involves using a DefaultContractResolver implementation that uses recursion along with some type mapping to reduce the Json.

Here is the contract resolver:

public class ShouldSerializeContractResolver : DefaultContractResolver
{
    protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
    {
        JsonProperty property = base.CreateProperty(member, memberSerialization);

        if (property.GetType().GetTypeName() == "object")
        {
            property.ShouldSerialize =
                instance =>
                {
                    var value = instance.GetType().GetProperty(property.UnderlyingName).GetValue(instance, null);

                    if (value == null)
                    {
                        return false;
                    }

                    if (value.GetType().GetTypeName() == "object")
                    {
                        if (NodeHasValue(value))
                        {
                            return true;
                        }
                    }
                    else
                    {
                        if (value.GetType().GetTypeName() == "collection")
                        {
                            ICollection enumerable = (ICollection)value;
                            if (enumerable.Count != 0)
                            {
                                return true;
                            }
                            else
                            {
                                return false;
                            }
                        }

                        return true;
                    }
                    return false;

                };
        }

        return property;
    }

    public bool NodeHasValue(object obj)
    {
        Type objType = obj.GetType();
        PropertyInfo[] properties = objType.GetProperties();

        foreach (PropertyInfo property in properties)
        {
            var value = property.GetValue(obj, null);

            if (value == null)
            {
                return false;
            }

            if (value.GetType().GetTypeName() == "object")
            {
                return NodeHasValue(value);
            }

            if (value.GetType().GetTypeName() == "collection")
            {
                ICollection enumerable = (ICollection)value;
                if (enumerable.Count != 0)
                {
                    return true;
                }
            }

            if (value.GetType().GetTypeName() == "array")
            {
                IList enumerable = (IList)value;
                if (enumerable.Count != 0)
                {
                    return true;
                }
            }

            if (value.GetType().GetTypeName() != "object" 
                && value.GetType().GetTypeName() != "collection" 
                && value.GetType().GetTypeName() != "array")
            {
                if (value != null)
                {
                    var attribute = property.GetCustomAttribute(typeof(DefaultValueAttribute)) as DefaultValueAttribute;

                    if (attribute.Value.ToString() != value.ToString())
                    {
                        return true;
                    }
                }
            }
        }

        return false;
    }
}

The method GetTypeName() is an extension method for the Type class, it works to identity what I specify are primitive types versus collections, objects, and arrays.

Extension method class for GetTypeName():

public static string GetTypeName(this Type type)
{
    if (type.IsArray)
    {
        return "array";
    }

    if (type.GetTypeInfo().IsGenericType)
    {
        if (type.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            return GetTypeName(Nullable.GetUnderlyingType(type)) + '?';
        }

        var genericTypeDefName = type.Name.Substring(0, type.Name.IndexOf('`'));
        var genericTypeArguments = string.Join(", ", type.GenericTypeArguments.Select(GetTypeName));

        if (type.GetTypeInfo().GetInterfaces().Any(ti => ti.GetGenericTypeDefinition() == typeof(IEnumerable<>)))
        {
            return "collection";
        }

        return $"{genericTypeDefName}<{genericTypeArguments}>";
    }

    string typeName;
    if (_primitiveTypeNames.TryGetValue(type, out typeName))
    {
        return typeName;
    }

    // enum's come up as a ValueType so we check IsEnum first.
    if (type.GetTypeInfo().IsEnum)
    {
        return "enum";
    }

    if (type.GetTypeInfo().IsValueType)
    {
        return "struct";
    }

    return "object";
}

private static readonly Dictionary<Type, string> _primitiveTypeNames = new Dictionary<Type, string>
{
    { typeof(bool), "bool" },
    { typeof(byte), "byte" },
    { typeof(byte[]), "byte[]" },
    { typeof(sbyte), "sbyte" },
    { typeof(short), "short" },
    { typeof(ushort), "ushort" },
    { typeof(int), "int" },
    { typeof(uint), "uint" },
    { typeof(long), "long" },
    { typeof(ulong), "ulong" },
    { typeof(char), "char" },
    { typeof(float), "float" },
    { typeof(double), "double" },
    { typeof(string), "string" },
    { typeof(decimal), "decimal" }
};

}

Jamie
  • 123
  • 2
  • 9
  • 1
    If anyone finds this, there are a few bugs in the above NodeHasValue: 1. if value == null we need to continue, not return false. 2. we need to ignore JsonIgnore marked properties with if (property.GetCustomAttribute(typeof(Newtonsoft.Json.JsonIgnoreAttribute)) != null) continue; 3. after attribute set of the DefaultValueAttribute need to check if attribute is not null if we want to use this part. After these fixes, it works by removing internal empty objects from json result. – Alon Gingold May 16 '22 at 23:19