This is a very awkward error and it took me a while to tackle it down to these few lines of code. The full code can be grabbed as fiddle, but you have to provide your Cosmos Url and Key to let it run. The problem is, we defined a public static readonly Author Unknown
field and the class Author
is a record, that is defined as follows:
public record Author(
[Required] string Id,
[Required] string DisplayName,
[Required] string MailAddress)
{
// This is going to be mutated! 👇🏻
public static readonly Author Unknown = Create(nameof(Unknown));
public static Author Create(string name)
{
return new Author(
name.ToLowerInvariant(),
name,
$"{name}@test.internal");
}
}
In a next step we have a class, that we want to read/write from/to Azure Cosmos database and the class can be as simple as
public class DocumentBase
{
[Newtonsoft.Json.JsonProperty("id")]
public Guid Id { get; set; } = default!;
public string PartitionKey { get; set; } = "PartitionKey";
// Apply static readonly record in default ctor of class
public Author Modified { get; set; } = Author.Unknown;
}
The next thing we need in our setup is a Cosmos DB container we can use to read/write our documents:
private static async Task<Container> CreateContainer()
{
var endPoint = "https://{yourCosmosDatabase}.documents.azure.com:443/";
var key = "{yourCosmosKey}";
var client = new CosmosClient(endPoint, key);
var containerId = $"{DateTime.UtcNow:s}-{Guid.NewGuid()}";
var partitionKey = "/PartitionKey";
var database = client.GetDatabase("Testing");
var response = await database.CreateContainerAsync(new ContainerProperties
{
Id = containerId,
PartitionKeyPath = partitionKey,
DefaultTimeToLive = -1
});
return response.Container;
}
Here here is the code, that changes the values in Author.Unknown
:
public static async Task Main()
{
// Create a container to access Cosmos
var container = await CreateContainer();
// Create an instance with individual values
var defaultValue = new DocumentBase
{
Id = Guid.NewGuid(),
Modified = Author.Create("Modified"),
};
// Create a copy of the static readonly record for comparison
var copyOfUnknown = Author.Unknown with { };
// Check if the copy and the original values are equal
// with value comparison thanks to the help of record
Author.Unknown.ShouldBe(copyOfUnknown);
// Let Cosmos write that instance down to the database
// and read back the fresh created value.
// It will be returned by the function, but we ignore it.
await container.CreateItemAsync(defaultValue, new PartitionKey(defaultValue.PartitionKey));
// Make another comparison that fails, because in Author.Unknown
// you find now Author.Create("Modified")!
Author.Unknown.ShouldBe(copyOfUnknown);
}
So what is going on here??
We have a public static readonly
field that stores a record
and after deserializing the response from Cosmos that field is mutated? How can that be?
To work around this problem we have multiple options, maybe showing what are the needed ingredients to run into this problem:
Modified
property in the default ctor of DocumentBase
Modified
property in the default ctor of DocumentBase
by using the copy ctor = Author.Unknown with { };
Nevertheless this behaviour was really unexpected and I would be glad if anyone could explain this behaviour. And by the way, we used the latest NuGet package of Microsoft.Azure.Cosmos 3.38.1.
This is a known issue with Json.NET and immutable records. There is a possibility that Json.NET may mutate an (apparently) immutable property when populating a preallocated instance such as your Author.Unknown
. For confirmation, see the Json.NET issue Deserialization appears to write over default record properties rather than create new ones #2451 [1].
As a workaround, you could tell Json.NET to ignore the compiler-generated properties and serialize using private readonly properties instead:
public record Author(
[property: Newtonsoft.Json.JsonIgnore][Required] string Id,
[property: Newtonsoft.Json.JsonIgnore][Required] string DisplayName,
[property: Newtonsoft.Json.JsonIgnore][Required] string MailAddress)
{
[Newtonsoft.Json.JsonProperty("Id")] string JsonId => Id;
[Newtonsoft.Json.JsonProperty("DisplayName")] string JsonDisplayName => Id;
[Newtonsoft.Json.JsonProperty("MailAddress")] string JsonMailAddress => MailAddress;
// Remainder unchanged
Working demo fiddle #1 here.
Another workaround suggested by JamesNK is to mark all Author
-valued properties with ObjectCreationHandling.Replace
:
public class DocumentBase
{
// Apply static readonly record in default ctor of class
[Newtonsoft.Json.JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)]
public Author Modified { get; set; } = Author.Unknown;
// Remainder unchanged
Working demo fiddle #2 here.
There might be options to do this automatically using a custom contract resolver, however I am not sure whether the Microsoft.Azure.Cosmos
client provides access to settings in order to modify the contract resolver used.
init
-only properties. From the docs:
When you use the positional syntax for property definition, the compiler creates:
A public autoimplemented property for each positional parameter provided in the record declaration.
- For record types and readonly record struct types: An init-only property.
- For record struct types: A read-write property.
I.e. your Author
class actually looks something like:
public class Author
{
public string Id { get; init; }
public string DisplayName { get; init; }
public string MailAddress { get; init; }
}
And, init
-only properties can be set by reflection. For confirmation see Init Only Setters:
[
init
property accessors] will not protect against the following circumstances:
- Reflection over public members
- The use of
dynamic
- ...
Since Json.NET (and other serializers) use reflection over public members to create their serialization contracts, init
-only properties look like settable properties, and so can get populated during deserialization.