aaubry / YamlDotNet

YamlDotNet is a .NET library for YAML
MIT License
2.48k stars 466 forks source link

OmitDefaults doesn't work for properties of sub-objects #840

Closed lunarcloud closed 10 months ago

lunarcloud commented 10 months ago

Describe the bug When using "DefaultValuesHandling.OmitDefaults", I would expect serializing an object with an object inside to omit defaults of that sub-object.

To Reproduce


public class ExampleB
{
    public ExampleB() { }

    [DefaultValue(3)]
    public int PropOfB { get; set; } = 3;
}

public class ExampleA
{
    public ExampleA() { }

    //[DefaultValue(null)] - can't set to anything but null, because can't make a const ExampleB
    public ExampleB PropOfA { get; set; } = new;
}

//...

[TestMethod]
public void TestThis() {
    var instanceOfA = new ExampleA(); // leaving the defaults
    var text = new SerializerBuilder()
            .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults)
            .Build()
            .Serialize(instanceOfA);

    Assert.AreNotEqual("PropOfA:\r\n  PropOfB: 3\r\n", text) // fails
}
lunarcloud commented 10 months ago

it seems to work if there are no objects inside that sub-object without defaults. I'm still not sure the exact scenarios this is and is-not working in

EdwardCooke commented 10 months ago

I haven’t looked to closely at this yet. But. According to this class/code in order to not output PropOfA you would need to set DefaultValue(null) on it.

The way you have your defaultvalue on the integer I would have assumed would work based on the below code. I’ll take a closer look maybe today.

https://github.com/aaubry/YamlDotNet/blob/a6845eba6cddf4ba0ff22a4e03d95781056bc208/YamlDotNet/Serialization/ObjectGraphVisitors/DefaultValuesObjectGraphVisitor.cs#L80

EdwardCooke commented 10 months ago

I do feel there is a bug in there since defaults of an instance type is always null. So that should probably be taken into account in that class.

lunarcloud commented 10 months ago

I do feel there is a bug in there since defaults of an instance type is always null. So that should probably be taken into account in that class.

So there's no way to omit a non-null default for a YAML sub-category or Array?

EdwardCooke commented 10 months ago

On an instance type you would need to add the defaultvalue of null attribute like you have commented out. I think. I haven’t tested or played with that. I was hoping to have time over the weekend but that didn’t happen.

EdwardCooke commented 10 months ago

Arrays are insurance type. So defaults of that would be null. But you couldn’t do an empty array as a default. If that makes sense.

lunarcloud commented 10 months ago

Right, so you're confirming that only primitives can have non-null defaults ( and there's no way to say "this is a static-readonly value I consider default for you to omit").

Okay. That's dissapointing. Default-omission is the only real way of writing configs that are safe from needing config versioning and a migrator tool between major software releases.

EdwardCooke commented 10 months ago

Ok. Got to spend about 5 minutes. You might. And I strongly emphasize might. Be able to create a custom System.ComponentModel.TypeDescriptionProvider. Register it with System.ComponentModel.TypeConverter.AddProvider.

Then when you specify the DefaultValue attribute put the type and probably and empty string.

You will also need to create an Equals override method on your class. Then you could do all sorts of things. No guarantees that it will work though.

ecooke-macu commented 10 months ago

Figured it out for you using TypeConverters and no changes necessary to the YamlDotNet code base. The following code will exclude the PropOfA attribute from the yaml if PropOfB is 3. This results in the output of {} (an empty Yaml). Is that what you are looking for?

var instanceOfA = new ExampleA(); // leaving the defaults
var text = new SerializerBuilder()
        .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults)
        .Build()
        .Serialize(instanceOfA);
Console.WriteLine(text);
Console.ReadLine();

public class ExampleBTypeConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
    {
        return sourceType == typeof(string);
    }

    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (string.IsNullOrEmpty(value as string))
        {
            // Default value to check against
            return new ExampleB { PropOfB = 3 };
        }

        return null;
    }
}

[TypeConverter(typeof(ExampleBTypeConverter))]
public class ExampleB
{
    public ExampleB() { }

    public int PropOfB { get; set; } = 3;

    public override bool Equals(object? obj)
    {
        return (obj as ExampleB)?.PropOfB == PropOfB;
    }
}

public class ExampleA
{
    public ExampleA() { }

    [DefaultValue(typeof(ExampleB), null)]
    public ExampleB PropOfA { get; set; } = new();
}
ecooke-macu commented 10 months ago

If you want to exclude PropOfB from the output, where PropOfA is still there, then this works by outputting PropOfA: {}:

var instanceOfA = new ExampleA(); // leaving the defaults
var text = new SerializerBuilder()
        .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults)
        .Build()
        .Serialize(instanceOfA);
Console.WriteLine(text);
Console.ReadLine();

public class ExampleB
{
    public ExampleB() { }

    [DefaultValue(3)]
    public int PropOfB { get; set; } = 3;

    public override bool Equals(object? obj)
    {
        return (obj as ExampleB)?.PropOfB == PropOfB;
    }
}

public class ExampleA
{
    public ExampleA() { }

    public ExampleB PropOfA { get; set; } = new();
}
lunarcloud commented 10 months ago

It doesn't solve my issues with arrays, but it absolutely fixes the bug report as it's written.

ecooke-macu commented 10 months ago

If you don't have to have it as an array, you could create another class inheriting List<ExampleB>. There's also another option if you do have to have it exposed as an array, use [YamlMember] and [YamlIgnore] and have one property be this subclassed list, and another being the array.

Like this:

var instanceOfA = new ExampleA
{
    PropOfA = new ExampleBList
    {
        new ExampleB
        {
            PropOfB = 3
        }
    }
};

var text = new SerializerBuilder()
        .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults)
        .Build()
        .Serialize(instanceOfA);
Console.WriteLine(text);
Console.ReadLine();

public class ExampleB
{
    public ExampleB() { }

    public int PropOfB { get; set; }

    public override bool Equals(object? obj)
    {
        return (obj as ExampleB)?.PropOfB == PropOfB;
    }
}

public class ExampleA
{
    public ExampleA() { }

    [DefaultValue(typeof(ExampleBList), null)]
    public ExampleBList PropOfA { get; set; }
}

public class ExampleBListTypeConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
    {
        return sourceType == typeof(string);
    }

    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (string.IsNullOrEmpty(value as string))
        {
            return new ExampleBList
            {
                new ExampleB()
                {
                     PropOfB = 3
                }
            };
        }

        return null;
    }
}

[TypeConverter(typeof(ExampleBListTypeConverter))]
public class ExampleBList : List<ExampleB>
{
    public override bool Equals(object? obj)
    {
        var list = obj as IEnumerable<ExampleB>;

        if (list != null)
        {
            if (this.Count == list.Count())
            {
                foreach (var item in this)
                {
                    //make sure each item in the incoming list only exists once in this list
                    if (list.Where(x => x.Equals(item)).Count() != 1)
                    {
                        return false;
                    }
                }
                return true;
            }
        }
        return false;
    }
}
ecooke-macu commented 10 months ago

If you need to have it exposed as an array, you can do something like this with the YamlMember and YamlIgnore attributes.

var instanceOfA = new ExampleA
{
    PropOfA = new []
    {
        new ExampleB
        {
            PropOfB = 3
        }
    }
};

var text = new SerializerBuilder()
        .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults)
        .Build()
        .Serialize(instanceOfA);
Console.WriteLine(text);
Console.ReadLine();

public class ExampleB
{
    public ExampleB() { }

    public int PropOfB { get; set; }

    public override bool Equals(object? obj)
    {
        return (obj as ExampleB)?.PropOfB == PropOfB;
    }
}

public class ExampleA
{
    public ExampleA() { }

    [DefaultValue(typeof(ExampleBList), null)]
    [YamlMember(Alias = "PropOfA")]
    public ExampleBList PropOfADefaulted { get; set; }

    [YamlIgnore]
    public ExampleB[] PropOfA
    {
        get
        {
            return PropOfADefaulted.ToArray();
        }
        set
        {
            PropOfADefaulted = new ExampleBList(value);
        }
    }
}

public class ExampleBListTypeConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
    {
        return sourceType == typeof(string);
    }

    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (string.IsNullOrEmpty(value as string))
        {
            return new ExampleBList
            {
                new ExampleB()
                {
                     PropOfB = 3
                }
            };
        }

        return null;
    }
}

[TypeConverter(typeof(ExampleBListTypeConverter))]
public class ExampleBList : List<ExampleB>
{
    public ExampleBList() { }
    public ExampleBList(IEnumerable<ExampleB> collection) : base(collection) { }

    public override bool Equals(object? obj)
    {
        var list = obj as IEnumerable<ExampleB>;

        if (list != null)
        {
            if (this.Count == list.Count())
            {
                foreach (var item in this)
                {
                    //make sure each item in the incoming list only exists once in this list
                    if (list.Where(x => x.Equals(item)).Count() != 1)
                    {
                        return false;
                    }
                }
                return true;
            }
        }
        return false;
    }
}
lunarcloud commented 10 months ago

Thank you very much. No my secondary problem was with having a default value for a primitive array like int[] but this is very very helpful info.