-1

Using .NET 6, System.Text.Json namespace to serialize some classes.

I'm trying to serialize some models from a database (represented as classes in C#) as JSON in a REST API. The models have one property that is the primary key in the database, for example, in the following Employee class, Id is the primary key.

public class Employee
{
  public int Id { get; set; } // <-- primary key
  public decimal Salary { get; set; }
}

If I serialize a collection of a class like this into JSON, I would get something like

[
  { Id: 0, Salary: XXXX.XX },
  { Id: 1, Salary: XXXX.XX }
]

How would I serialize this to

[
  0: {
    Salary: XXXX.XX
  },
  1: {
    Salary: XXXX.XX
  }
]

So that the primary key becomes the object keys in the final JSON?

My current idea is to define the collection as a property in another class that will be serialized, and put a JsonConverter attribute on this property that converts IEnumerable to Dictionary, but I'm stuck on how to let the program know which property is the identifying key.

dbc
  • 104,963
  • 20
  • 228
  • 340
Joqsun
  • 9
  • 1
  • 1) Just to confirm, you are using .NET 6 not .NET 7? 2) Do you need to deserialize as well, or only serialize? 3) Can you modify your models to add attributes? 4) Why not just convert your list of models to a dictionary of DTOs, and return that? Do you need to do this for many different models? – dbc May 11 '23 at 21:18
  • Also, your second, desired JSON sample is malformed, a JSON object **must** start with `{` and end with `}`. Please confirm that you actually need a well-formed JSON object. – dbc May 11 '23 at 22:39

2 Answers2

0

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.

dbc
  • 104,963
  • 20
  • 228
  • 340
0

you can try this code

var salaries = new List<Employee> { new Employee { Id = 0, Salary = 123.45M },
                                    new Employee { Id = 1, Salary = 678.90M }
                                  };

var options = new JsonSerializerOptions { WriteIndented = true };                             
var json =  System.Text.Json.JsonSerializer.Serialize(
             salaries.ToDictionary(a => a.Id, a => new {Salary=a.Salary}),options);     

result

{
  "0": {
    "Salary": 123.45
  },
  "1": {
    "Salary": 678.90
  }
}

if you want in one line you need to learn how to create methods in c#

var json =  SerializeToDictionary(employes);

the method

public string SerializeToDictionary(List<Employee> employees)
{
var options = new JsonSerializerOptions { WriteIndented = true };
return System.Text.Json.JsonSerializer.Serialize(
    employees.ToDictionary(a => a.Id, a => new { Salary = a.Salary }), options);
}       

when you learn how to use generics you can create more universal methods

Serge
  • 40,935
  • 4
  • 18
  • 45