Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

System.Text.Json Serializer Behavior Changes Depending on Object Interfaces #64302

Closed
cspital opened this issue Jan 25, 2022 · 2 comments
Closed
Labels
area-System.Text.Json question Answer questions and provide assistance, not an issue with source code or documentation.

Comments

@cspital
Copy link

cspital commented Jan 25, 2022

Description

JsonSerializer.Serialize<T>(T value, [JsonSerializerOptions options = null]) behavior changes depending on interface implementation level within inheritance hierarchy when hiding parent type properties.

Reproduction Steps

using System.Text.Json;
using System.Text.Json.Serialization;

var opts = new JsonSerializerOptions
{
    Converters =
    {
        new JsonStringEnumConverter()
    },
    WriteIndented = true,
};

var thing = new EnrichedThing
{
    Key = "A",
    Metadata = "Source=Test",
    Name = "EnrichedThing",
    Number = 123.45,
    Expires = DateTime.Now,
    Extra = "Extra"
};

var proxy = new ProxiedThing
{
    Key = "B",
    Metadata = "Source=Test",
    Name = "ProxiedThing",
    Number = 123.45,
    Expires = DateTime.Now,
    Extra = "Extra"
};

Console.WriteLine(JsonSerializer.Serialize(thing, opts));
Console.WriteLine(JsonSerializer.Serialize(proxy, opts));

public interface IKey
{
    string Key { get; set; }
    string Metadata { get; set; }
}

public class Model : IKey
{
    public virtual string Key { get; set; }
    public virtual string Metadata { get; set; }
}

public interface IEnriched
{
    double Number { get; set; }
    DateTime Expires { get; set; }
}

public class ModelBase : Model, IEnriched
{
    public double Number { get; set; }
    public DateTime Expires { get; set; }
    public string Extra { get; set; }
}

public class EnrichedBase : ModelBase, IEnriched // implement IEnriched at this level
{
    [JsonIgnore]
    public new double Number { get; set; }
    [JsonIgnore]
    public new DateTime Expires { get; set; }
    [JsonIgnore]
    public new string Extra { get; set; }
}

public class EnrichedThing : EnrichedBase
{
    public string Name { get; set; }
}

public class ProxiedBase : ModelBase // allow IEnriched implementation to come from ModelBase
{
    [JsonIgnore]
    public new double Number { get; set; }
    [JsonIgnore]
    public new DateTime Expires { get; set; }
    [JsonIgnore]
    public new string Extra { get; set; }
}

public class ProxiedThing : ProxiedBase
{
    public string Name { get; set; }
}

Expected behavior

Both serialized payloads should look the same, preferably like Thing below. I understand System.Text.Json doesn't support polymorphic hierarchies at the moment so I won't get into the correctness of the Extra property appearing in the first place. I would not expect the level at which an interface is satisfied to be relevant with regards to serialization, especially when the value passed to the serializer is the most specific concrete type and it is never held as an implementation of that interface.

Actual behavior

The payloads are different for thing and proxy.

Thing:

{
  "Name": "EnrichedThing",
  "Extra": null,
  "Key": "A",
  "Metadata": "Source=Test"
}

Proxy:

{
  "Name": "ProxiedThing",
  "Number": 0,
  "Expires": "0001-01-01T00:00:00",
  "Extra": null,
  "Key": "B",
  "Metadata": "Source=Test"
}

Regression?

I don't know.

Known Workarounds

Marking ModelBase properties as virtual and overriding in derived types resolves the issue.

Configuration

Which version of .NET is the code running on? 6.0.100
What OS and version, and what distro if applicable? Windows 10
What is the architecture (x64, x86, ARM, ARM64)? x64
Do you know whether it is specific to that configuration? I don't know.

Other information

No response

@dotnet-issue-labeler dotnet-issue-labeler bot added area-System.Text.Json untriaged New issue has not been triaged by the area owner labels Jan 25, 2022
@ghost
Copy link

ghost commented Jan 25, 2022

Tagging subscribers to this area: @dotnet/area-system-text-json
See info in area-owners.md if you want to be subscribed.

Issue Details

Description

JsonSerializer.Serialize<T>(T value, [JsonSerializerOptions options = null]) behavior changes depending on interface implementation level within inheritance hierarchy when hiding parent type properties.

Reproduction Steps

using System.Text.Json;
using System.Text.Json.Serialization;

var opts = new JsonSerializerOptions
{
    Converters =
    {
        new JsonStringEnumConverter()
    },
    WriteIndented = true,
};

var thing = new EnrichedThing
{
    Key = "A",
    Metadata = "Source=Test",
    Name = "EnrichedThing",
    Number = 123.45,
    Expires = DateTime.Now,
    Extra = "Extra"
};

var proxy = new ProxiedThing
{
    Key = "B",
    Metadata = "Source=Test",
    Name = "ProxiedThing",
    Number = 123.45,
    Expires = DateTime.Now,
    Extra = "Extra"
};

Console.WriteLine(JsonSerializer.Serialize(thing, opts));
Console.WriteLine(JsonSerializer.Serialize(proxy, opts));

public interface IKey
{
    string Key { get; set; }
    string Metadata { get; set; }
}

public class Model : IKey
{
    public virtual string Key { get; set; }
    public virtual string Metadata { get; set; }
}

public interface IEnriched
{
    double Number { get; set; }
    DateTime Expires { get; set; }
}

public class ModelBase : Model, IEnriched
{
    public double Number { get; set; }
    public DateTime Expires { get; set; }
    public string Extra { get; set; }
}

public class EnrichedBase : ModelBase, IEnriched // implement IEnriched at this level
{
    [JsonIgnore]
    public new double Number { get; set; }
    [JsonIgnore]
    public new DateTime Expires { get; set; }
    [JsonIgnore]
    public new string Extra { get; set; }
}

public class EnrichedThing : EnrichedBase
{
    public string Name { get; set; }
}

public class ProxiedBase : ModelBase // allow IEnriched implementation to come from ModelBase
{
    [JsonIgnore]
    public new double Number { get; set; }
    [JsonIgnore]
    public new DateTime Expires { get; set; }
    [JsonIgnore]
    public new string Extra { get; set; }
}

public class ProxiedThing : ProxiedBase
{
    public string Name { get; set; }
}

Expected behavior

Both serialized payloads should look the same, preferably like Thing below. I understand System.Text.Json doesn't support polymorphic hierarchies at the moment so I won't get into the correctness of the Extra property appearing in the first place. I would not expect the level at which an interface is satisfied to be relevant with regards to serialization, especially when the value passed to the serializer is the most specific concrete type and it is never held as an implementation of that interface.

Actual behavior

The payloads are different for thing and proxy.

Thing:

{
  "Name": "EnrichedThing",
  "Extra": null,
  "Key": "A",
  "Metadata": "Source=Test"
}

Proxy:

{
  "Name": "ProxiedThing",
  "Number": 0,
  "Expires": "0001-01-01T00:00:00",
  "Extra": null,
  "Key": "B",
  "Metadata": "Source=Test"
}

Regression?

I don't know.

Known Workarounds

Marking ModelBase properties as virtual and overriding in derived types resolves the issue.

Configuration

Which version of .NET is the code running on? 6.0.100
What OS and version, and what distro if applicable? Windows 10
What is the architecture (x64, x86, ARM, ARM64)? x64
Do you know whether it is specific to that configuration? I don't know.

Other information

No response

Author: cspital
Assignees: -
Labels:

area-System.Text.Json, untriaged

Milestone: -

@eiriktsarpalis
Copy link
Member

behavior changes depending on interface implementation level within inheritance hierarchy when hiding parent type properties.

This is by design, the serialization contract employed is type directed and does not reflect the runtime type of the serialized object. You should still be able to influence the contract by the changing the type of either the root value or types of members like so:

Console.WriteLine(JsonSerializer.Serialize<ModelBase>(thing, opts));
Console.WriteLine(JsonSerializer.Serialize<ModelBase>(proxy, opts));

producing

{
  "Number": 0,
  "Expires": "0001-01-01T00:00:00",
  "Extra": null,
  "Key": "A",
  "Metadata": "Source=Test"
}
{
  "Number": 0,
  "Expires": "0001-01-01T00:00:00",
  "Extra": null,
  "Key": "B",
  "Metadata": "Source=Test"
}

Your example also showcases a known issue with how JsonIgnoreAttribute is handled in overridden or shadowed members, tracked by #63443 and #50078.

@eiriktsarpalis eiriktsarpalis added question Answer questions and provide assistance, not an issue with source code or documentation. and removed untriaged New issue has not been triaged by the area owner labels Jan 27, 2022
@ghost ghost locked as resolved and limited conversation to collaborators Feb 26, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Text.Json question Answer questions and provide assistance, not an issue with source code or documentation.
Projects
None yet
Development

No branches or pull requests

2 participants