0

I have a nested object that looks like:

public record Options
{
    public BatterySettings BatterySettings { get; init; } = new();
    public LogSettings LogSettings { get; init; } = new();
}

public record LogSettings 
{
    public string SourceName { get; init; } = "Default";
}

public record BatterySettings
{
    public int BatteryLevel { get; init; } = 5;
    public string BatteryHealth { get; init; } = "Normal";
    public BatteryLocations BatteryLocation { get; init; } = BatteryLocations.North;
}

public enum BatteryLocations
{
    North,
    South
}

After initializing the object and setting some properties i.e.:

var opt = new Options 
{
    BatterySettings = new BatterySettings {
        BatteryLevel = 10,
        BatteryHealth = "Low"
    }   
}

I would like to get a JSON string that represents this object opt while having all the default value set to null i.e. in this above example, the resulting opt JSON string would look like:

{
   "BatterySettings":{
      "BatteryLevel":10,
      "BatteryHealth":"Low",
      "BatteryLocation":null
   },
   "LogSettings":{
      "SourceName":null
   }
}

Is there a built-in way in .NET to do such a thing?

Edit 1: the built-in way of utilizing the null serialization settings would not work since the object Options has non-null default values for its properties and sub-object properties. It seems that a custom converter would need to be implemented here though I have trouble figuring out the correct approach to this due to having to compare default values with the object's current value for every given nodes

K. Vu
  • 95
  • 1
  • 8
  • 1
    BatteryLocation is an enum, so having null is not a valid value for this property. You can make this nullable and assign default value as null – Jasmeet Nov 07 '22 at 10:22
  • Just remove "Default" and "Normal from your class.Default value for a string is null. Also it is not the best idea to use new() in your root class. It is just an extra work that is not needed. – Serge Nov 07 '22 at 12:35
  • @Jasmeet I would like to have the resulting json string to have the property BatteryLocation: null if the object's current value is == to the default object value (in this case BatteryLocations.North) – K. Vu Nov 07 '22 at 18:36
  • @Serge I can't remove the default values since they are part of the design constraints, hence the challenging aspect of this question unfortunately – K. Vu Nov 07 '22 at 18:37

2 Answers2

1

In case of Json.Net there is a NullValueHandling settings under the JsonSerializerSettings where you can control the null values' serialization.

var settings new JsonSerializerSettings
{
    NullValueHandling = NullValueHandling.Include
};
string json = JsonConvert.SerializeObject(opt, Formatting.Indented, settings);

In case of System.Text.Json there is a WhenWritingNull settings (please be aware that IgnoreNullValues is obsolete) under the JsonSerializerOptions where you can control the null values' serialization.

var options = new JsonSerializerOptions()
{
    DefaultIgnoreCondition = JsonIgnoreCondition.Never
};

string json = JsonSerializer.Serialize<Options>(opt,options);
Peter Csala
  • 17,736
  • 16
  • 35
  • 75
  • Thank you for the reply, however the issue with using those null value serialization is that they wouldn't work in my example since the object 'Options' has non-null default values so when serializing the object, all the default value fields would be populated with the default values in the json, which I do not want. Id like those fields to be null instead – K. Vu Nov 07 '22 at 18:32
  • 1
    @K.Vu If you want to serialize a non-nullable field as null if it has a default value then you need to write a custom converter. There is no built-in support for that because this use case is quite rare IMHO. – Peter Csala Nov 07 '22 at 19:25
  • I see, I figured some sort of custom solution would need to be done. Do you have any pointers as to how to approach a case like this though? I could serialize the object 'opt' as a JToken/JContainer and loop through its children, but I have trouble visualizing how I can loop through the default values of the Options object to perform a comparison to set the former's properties to null if it is a default value. – K. Vu Nov 07 '22 at 20:40
  • 1
    @K.Vu I've posted another solution proposal, please check it! – Peter Csala Nov 08 '22 at 09:35
1

In this post I will show you how can you serialize the BatterySettings

  • if the property value is the same as the auto-generated property's default value then serialize it as null
  • if the property value is different than the auto-generated property's default value then serialize it as it is

In your particular case the default values of your auto-generated properties may or may not be the same as the run-time defaults. So, we can't use the default operator. To solve this problem I suggest the following "trick"

public record BatterySettings
{
    private const int BatteryLevelDefault = 5;
    public int BatteryLevel { get; init; } = BatteryLevelDefault;

    private const string BatteryHealthDefault = "Normal";
    public string BatteryHealth { get; init; } = BatteryHealthDefault;

    private const BatteryLocations BatteryLocationDefault = BatteryLocations.North;
    public BatteryLocations BatteryLocation { get; init; } = BatteryLocationDefault;
}

So, the "trick" is that we have a dedicated constant field for each property to store the default values. I've marked them as private so other class can't access them only via reflection.


Now let's see how the converter looks like for this data structure.
(Please note that this is not production-ready code. It is just for demonstration purposes.)

class BatterySettingsConverter : JsonConverter<BatterySettings>
{
    private readonly PropertyInfo[] Properties = typeof(BatterySettings).GetProperties();
    private readonly FieldInfo[] ConstFields = typeof(BatterySettings).GetFields(BindingFlags.NonPublic | BindingFlags.Static);

    public override BatterySettings? ReadJson(JsonReader reader, Type objectType, BatterySettings? existingValue, bool hasExistingValue, JsonSerializer serializer)
        => throw new NotImplementedException();

    public override void WriteJson(JsonWriter writer, BatterySettings? value, JsonSerializer serializer)
    {
        var result = new JObject();
        foreach (PropertyInfo prop in Properties)
        {
            var defaultValueField = ConstFields.FirstOrDefault(fi => fi.Name.StartsWith(prop.Name));
            if (!prop.CanRead || defaultValueField == null)
                continue;
            
            object propVal = prop.GetValue(value);
            object defaultVal = defaultValueField.GetValue(value);
            JToken serializeVal = !propVal.Equals(defaultVal) ? JToken.FromObject(propVal, serializer) : null;

            result.Add(prop.Name, serializeVal);           
        }
        result.WriteTo(writer);
    }
}
  • I've stored on the class-level the properties and the fields of the BatterySettings record
  • Inside the WriteJson, first I create an accumulator object (result)
  • Then I iterate through the properties and try to find the matching field
    • If the related field is not exist / the property does not have a getter then I simply skip it
    • This logic could be and should be tailored to your needs
  • I retrieve the property's actual value and the constant field's value
  • Based on the result of equality check I decide what to serialize
  • At the very end I ask json.net to perform the serialization of the accumulator object

After I have decorated the BatterySettings with the following attribute [JsonConverter(typeof(BatterySettingsConverter))] then I can perform some testing

var x = new BatterySettings();
var json = JsonConvert.SerializeObject(x); 
//{"BatteryLevel":null,"BatteryHealth":null,"BatteryLocation":null}

var y = new BatterySettings() { BatteryHealth = "A"}; 
json = JsonConvert.SerializeObject(y); 
//{"BatteryLevel":null,"BatteryHealth":"A","BatteryLocation":null}

var z = new BatterySettings() { BatteryLocation = BatteryLocation.South};
json = JsonConvert.SerializeObject(z); 
//{"BatteryLevel":null,"BatteryHealth":null,"BatteryLocation":1}

You can apply the same logic for the rest of your domain classes/records/sturcts.

Peter Csala
  • 17,736
  • 16
  • 35
  • 75
  • 1
    Thank you for the reply, I really appreciate it! This solution certainly works in the case of a single class, however this would mean that for objects containing sub-classes a new custom JsonConverter would have to be implemented for every object? Is it possible to perform a loop in the WriteJson method that goes through all the objects properties recursively in a Breadth First Search manner to perform the conditional check to set the prop value to null? Basically I am trying to see how I can write just 1 custom converter for the top-most class only (in this case the record Options) – K. Vu Nov 08 '22 at 10:18
  • 1
    @K.Vu I've shared with you the basic idea how to perform your conditional serialization logic. I think this can be easily generalized in the way that the converter can serialize any data structure in the same way. Hints: 1) Use `JsonConverter` rather than `JsonConverter` 2) Do not use class-level members to store property and field infos. – Peter Csala Nov 08 '22 at 10:28
  • I see! Could you elaborate a bit more on your point `2) Do not use class-level members to store property and field infos`, I'm not sure I totally understand it since in your proposed solution all the default values are private and contained at the class level for each object – K. Vu Nov 10 '22 at 08:09
  • 1
    @K.Vu In my example I had two class-level readonly members (`Properties` and `ConstFields`) . If you want to create a converter which can work against multiple types then these information should be retrieved inside the `WriteJson` through `value.GetType()`. In the [`JsonConverter`](https://www.newtonsoft.com/json/help/html/CustomJsonConverter.htm) the `WriteJson`'s `value` parameter's type is `object`, not a strongly typed parameter. Is it clear know or shall I further elaborate on some part? – Peter Csala Nov 10 '22 at 08:26
  • 1
    Thank you for the precision! The solution worked beautifully by using Newtonsoft (Json.net)'s JsonConverter without a type, however I am trying to implement this custom converter using the `System.Text.Json JsonConverter` and the latter seem to require a specific type to be passed i.e. `JsonConverter`. Do you know if it is possible to implement a non-typed JsonConverter using `System.Text.Json.JsonConverter`? – K. Vu Nov 14 '22 at 08:57
  • 1
    @K.Vu Well, the [non-generic `JsonConverter`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonconverter?view=net-7.0) is just an `abstract` class which does not expose anything useful from serialization perspective. You can create a `JsonConverter` to mimic the Json.NET's `JsonConverter`. For more details, please check out this [documentation page](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/converters-how-to?pivots=dotnet-7-0). – Peter Csala Nov 14 '22 at 09:02