4

We have a problem when serializing using System.Text.Json.JsonSerializer.

In this example, we have three classes: Store, Employee, and Manager. It is noted that Manager inherits from Employee.

public class Employee
{
    public string Name { get; set; }

    public int Age { get; set; }
}

public class Manager : Employee
{
    public int AllowedPersonalDays { get; set; }
}

public class Store
{
    public Employee EmployeeOfTheMonth { get; set; }

    public Manager Manager { get; set; }

    public string Name { get; set; }
}

In the class Store, we have a property called EmployeeOfTheMonth. Well, as an example, suppose this property referenced the same object as the Manager property. Because EmployeeOfTheMonth is serialized first, it will ONLY serialize the Employee properties. When serializing the Manager property -- because it is second and the same object -- it will add a reference to the EmployeeOfTheMonth. When we do this, we're losing the additional property attached to the Manager, which is AllowedPersonalDays. Additionally, as you can see, it will not deserialize because -- while a Manager is an Employee -- an Employee is not a Manager.

Here's our short example:

Manager mgr = new Manager()
{
    Age = 42,
    AllowedPersonalDays = 14,
    Name = "Jane Doe",
};

Store store = new Store()
{
    EmployeeOfTheMonth = mgr,
    Manager = mgr,
    Name = "ValuMart"
};

System.Text.Json.JsonSerializerOptions options = new System.Text.Json.JsonSerializerOptions();
options.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve;

string serialized = System.Text.Json.JsonSerializer.Serialize<Store>(store, options);
var deserialized = System.Text.Json.JsonSerializer.Deserialize<Store>(serialized, options); // <-- Will through an exception per reasons stated above

If we look at the variable serialized, this is the content:

{
  "$id":"1",
  "EmployeeOfTheMonth": {
    "$id":"2",
    "Name":"Jane Doe",
    "Age":42
  },
  "Manager": {
    "$ref":"2"
  },
  "Name":"ValuMart"
}

Using System.Text.Json.JsonSerializer, how can we get the EmployeeOfTheMonth to correctly serialize as a Manager? That is, we need the serialization to look like the following:

{
  "$id":"1",
  "EmployeeOfTheMonth": {
    "$id":"2",
    "Name":"Jane Doe",
    "Age":42,
    "AllowedPersonalDays":14         <-- We need to retain this property even if the EmployeeOfTheMonth is a Manager
  },
  "Manager": {
    "$ref":"2"
  },
  "Name":"ValuMart"
}

I know I can adjust the ORDER of the properties in the Store class, but this is not an option and a very poor choice. Thank you, all.

Trecius Veil
  • 121
  • 8
  • I think you can workaround this issue using custom serializer which will support typehandling-like serialization/deserialization (using some [discriminator](https://stackoverflow.com/a/59744873/2501279), not actual type). – Guru Stron Oct 23 '21 at 01:23
  • Do you need to **deserialize**, or just serialize? – dbc Oct 24 '21 at 20:07
  • If you need to deserialize as well as serialize, the requirement to combine reference tracking with a custom converter is tricky -- and not directly supported by MSFT, see [ ReferenceHandler.IgnoreCycles doesn't work with Custom Converters #51715 ](https://github.com/dotnet/runtime/issues/51715). But if you only need to serialize there are some fairly simple approaches to get what you want. – dbc Oct 25 '21 at 18:17
  • Yes, we need to serialize as well as deserialize. I think I need to create a custom converter. Thank you, all, for your assistance. – Trecius Veil Oct 25 '21 at 23:05
  • [This answer](https://stackoverflow.com/a/69764443/3744182) by [Alexander Sheremetyev](https://stackoverflow.com/users/8362084/alexander-sheremetyev) to [Resolve cycle references of complex type during JSON serialization using System.Text.Json.Serialization.JsonConverter](https://stackoverflow.com/q/69741572/3744182) shows how to write a custom converter that also emits reference information. Doing so requires implementing a custom `ReferenceHandler` since MSFT doesn't make their internal reference handler available to the converter as of .NET 5. – dbc Oct 30 '21 at 17:20

1 Answers1

1

The documentation on writing custom converters has a very similar example (discriminating between two subclasses of a property's declared type) and could be adapted as follows:

public class EmployeeConverter : JsonConverter<Employee>
{
    enum TypeDiscriminator
    {
        Employee = 1,
        Manager = 2
    }

    private static string s_typeDiscriminatorLabel = "$TypeDiscriminator";

    public override bool CanConvert(Type typeToConvert) =>
        typeof(Employee).IsAssignableFrom(typeToConvert);

    public override Employee Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException();
        }

        reader.Read();
        if (reader.TokenType != JsonTokenType.PropertyName)
        {
            throw new JsonException();
        }

        string propertyName = reader.GetString();
        if (propertyName != s_typeDiscriminatorLabel)
        {
            throw new JsonException();
        }

        reader.Read();
        if (reader.TokenType != JsonTokenType.Number)
        {
            throw new JsonException();
        }

        // Instantiate type based on type discriminator value
        TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
        Employee employee = typeDiscriminator switch
        {
            TypeDiscriminator.Employee => new Employee(),
            TypeDiscriminator.Manager => new Manager(),
            _ => throw new JsonException()
        };

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                return employee;
            }

            if (reader.TokenType == JsonTokenType.PropertyName)
            {
                propertyName = reader.GetString();
                reader.Read();
                switch (propertyName)
                {
                    case "Name":
                        string name = reader.GetString();
                        employee.Name = name;
                        break;
                    case "Age":
                        int age = reader.GetInt32();
                        employee.Age = age;
                        break;
                    case "AllowedPersonalDays":
                        int allowedPersonalDays = reader.GetInt32();
                        if(employee is Manager manager)
                        {
                            manager.AllowedPersonalDays = allowedPersonalDays;
                        }
                        else
                        {
                            throw new JsonException();
                        }
                        break;
                }
            }
        }

        throw new JsonException();
    }

    public override void Write(
        Utf8JsonWriter writer, Employee person, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        // Write type indicator based on whether the runtime type is Manager
        writer.WriteNumber(s_typeDiscriminatorLabel, (int)(person is Manager ? TypeDiscriminator.Manager : TypeDiscriminator.Employee));

        writer.WriteString("Name", person.Name);
        writer.WriteNumber("Age", person.Age);

        // Write Manager-ony property only if runtime type is Manager
        if(person is Manager manager)
        {
            writer.WriteNumber("AllowedPersonalDays", manager.AllowedPersonalDays);
        }

        writer.WriteEndObject();
    }
}

Add an instance of your custom converter and it should deserialize correctly:

options.Converters.Add(new EmployeeConverter());

string serialized = JsonSerializer.Serialize<Store>(store, options);
var deserialized = JsonSerializer.Deserialize<Store>(serialized, options);
string reserialized = JsonSerializer.Serialize<Store>(deserialized, options);

System.Diagnostics.Debug.Assert(serialized == reserialized, "Manager property should be retained");
Mathias R. Jessen
  • 157,619
  • 12
  • 148
  • 206
  • This doesn't actually generate the required `"$id"` and `"$ref"` properties though, does it? – dbc Oct 25 '21 at 23:38