One way to identify the dictionary key property would be to introduce some custom attribute for that purpose, then mark all the relevant properties with the key.
Following this approach, you would first introduce the following [JsonListKey]
attribute:
[System.AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class JsonListKeyAttribute : System.Attribute { }
Next, add the attribute to any relevant classes:
public class Employee
{
[JsonListKey] public int Id { get; set; } // <-- primary key
public decimal Salary { get; set; }
}
Now you can introduce the following converter factory that will automatically serialize any List<TItem>
collections where TItem
has a [JsonListKey]
property as dictionaries:
public class CollectionToKeyedDictionaryConverter : JsonConverterFactory
{
JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions(); // In .NET 7 use JsonSerializerOptions.Default
public override bool CanConvert(Type typeToConvert) => TryGetItemJsonKeyProperty(typeToConvert, DefaultOptions, out var _, out var _, out var _);
static bool TryGetItemJsonKeyProperty(Type typeToConvert, JsonSerializerOptions options,
[System.Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] out Type? itemType,
[System.Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] out string? keyName,
[System.Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] out Type? keyType)
{
if (!typeToConvert.IsArray // Arrays are not implemented
&& (itemType = typeToConvert.GetListItemType()) is not null
&& typeToConvert.GetConstructor(Type.EmptyTypes) is not null
&& itemType.GetProperties().Where(p => Attribute.IsDefined(p, typeof(JsonListKeyAttribute))).SingleOrDefault() is {} property)
{
var attr = property.GetCustomAttribute<JsonPropertyNameAttribute>(true);
keyName = attr?.Name ?? options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
keyType = property.PropertyType;
return true;
}
keyName = default;
keyType = default;
itemType = default;
return false;
}
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
if (!TryGetItemJsonKeyProperty(typeToConvert, options, out var itemType, out var keyName, out var keyType))
throw new InvalidOperationException();
return (JsonConverter)Activator.CreateInstance(typeof(CollectionToKeyedDictionaryConverterInner<,>).MakeGenericType(typeToConvert, itemType), new object [] { options, keyName, keyType })!;
}
class CollectionToKeyedDictionaryConverterInner<TCollection, TItem> : JsonConverter<TCollection> where TCollection : class, ICollection<TItem>, new()
{
readonly string primaryKeyName;
readonly Type primaryKeyType;
static bool IsSerializedAsString(Type type) => type == typeof(string) || type == typeof(Guid); // Prevent double-quoting of string and Guid keys. Add others as required.
public CollectionToKeyedDictionaryConverterInner(JsonSerializerOptions options, string keyName, Type keyType) => (this.primaryKeyName, this.primaryKeyType) = (keyName, keyType);
public override TCollection Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException();
var collection = new TCollection();
while (reader.ReadAndAssert().TokenType != JsonTokenType.EndObject)
{
if (reader.TokenType != JsonTokenType.PropertyName)
throw new JsonException();
var currentName = reader.GetString()!;
if (reader.ReadAndAssert().TokenType != JsonTokenType.StartObject)
throw new JsonException();
var node = JsonNode.Parse(ref reader)!;
node[primaryKeyName] = (IsSerializedAsString(primaryKeyType) ? currentName : JsonSerializer.SerializeToNode(JsonSerializer.Deserialize(currentName, primaryKeyType, options), options));
collection.Add(node.Deserialize<TItem>(options)!);
}
return collection;
}
public override void Write(Utf8JsonWriter writer, TCollection value, JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (var item in value)
{
var node = JsonSerializer.SerializeToNode(item, options) as JsonObject;
if (node == null)
// TODO: decide what to do here. Throw an exception?
throw new InvalidOperationException("Non-object value");
var keyNode = node[primaryKeyName];
if (keyNode == null)
// TODO: decide what to do here. Throw an exception?
throw new InvalidOperationException("No key node");
var key = IsSerializedAsString(primaryKeyType) ? keyNode.Deserialize<string>()! : keyNode.ToJsonString();
node.Remove(primaryKeyName);
writer.WritePropertyName(key);
node.WriteTo(writer);
}
writer.WriteEndObject();
}
}
}
public static class JsonExtensions
{
public static ref Utf8JsonReader ReadAndAssert(ref this Utf8JsonReader reader) { if (!reader.Read()) { throw new JsonException(); } return ref reader; }
public static Type? GetListItemType(this Type type)
{
// Quick reject for performance
if (type.IsPrimitive || type.IsArray || type == typeof(string))
return null;
while (type != null)
{
if (type.IsGenericType)
{
var genType = type.GetGenericTypeDefinition();
if (genType == typeof(List<>))
return type.GetGenericArguments()[0];
}
type = type.BaseType!;
}
return null;
}
}
Then if you add the converter to JsonSerializerOptions.Converters
or directly to a property, you will get the required dictionary serialization format:
var list = new List<Employee>() { new () { Id = 0, Salary = 1111.11m }, new () { Id = 1, Salary = 2222.22m }, };
var options = new JsonSerializerOptions
{
Converters = { new CollectionToKeyedDictionaryConverter() },
// Other options as required
WriteIndented = true,
};
var json = JsonSerializer.Serialize(list, options);
var list2 = JsonSerializer.Deserialize<List<Employee>>(json, options);
// Assert that list and list2 are identically serialized without using the converter.
Assert.That(JsonSerializer.Serialize(list) == JsonSerializer.Serialize(list2), string.Format("{0} == {1}", JsonSerializer.Serialize(list), JsonSerializer.Serialize(list2)));
With the required JSON output
{
"0": {
"Salary": 1111.11
},
"1": {
"Salary": 2222.22
}
}
Demo fiddle here.