1

For some dynamic databinding in Blazor controls, I have to use a model like :

public class DynamicDataGridResult
{
    public DynamicDataGridResult()
    {
        Columns = new Dictionary<string, ColumnDefinition>();
        Data = new List<Dictionary<string, object>>();
    }

    public Dictionary<string, ColumnDefinition> Columns { get; set; }
    
    public List<Dictionary<string, object>> Data { get; set; }   
}

public class ColumnDefinition
{
    public string PropertyName { get; set; }
    public string TypeFullName { get; set; }
    public string Title { get; set; }
    public string? Formatter { get; set; }

    public Type GetColumnType()
    {
        var type = Type.GetType(TypeFullName);
        return type;
    }
}

In Columns, I describe my columns (TypeFullName to use for displaying, directly in .Net fashion, formatter for fine tuning ToString(xx), etc).

In Data, I can put my values.

Here's a working sample for both (code below is only here for example purpose, to illustrate with sample data but not important for my problem, see below if you want to skip please) :

        DynamicDataGridResult dataGridModel = new();

    var columns = new List<ColumnDefinition>()
    {
        new() { TypeFullName = typeof(int).FullName!, PropertyName = "EmployeeID", Title = "Employee ID"},
        new() { TypeFullName = typeof(decimal?).FullName!, PropertyName = "Rating", Title = "Rating (Nullable<decimal>)" },
        new() { TypeFullName = typeof(string).FullName!, PropertyName = "FirstName", Title = "First name"},
        new() { TypeFullName = typeof(string).FullName!, PropertyName = "LastName", Title = "Last name"},
        new() { TypeFullName = typeof(DateTime).FullName!, PropertyName ="HireDate", Title = "Hire date"},
    };

    columns.ForEach(c =>
    {
        if (c.TypeFullName.Contains("Decimal", StringComparison.OrdinalIgnoreCase)
            || c.TypeFullName.Contains("Double", StringComparison.OrdinalIgnoreCase)
            || c.TypeFullName.Contains("Single", StringComparison.OrdinalIgnoreCase))
        {
            c.Formatter = "{0:N2}";
        }
    });

    dataGridModel.Columns = columns.ToDictionary(k => k.PropertyName);

    dataGridModel.Data = Enumerable.Range(0, 25).Select(i =>
    {
        var row = new Dictionary<string, object>();

        foreach (var column in dataGridModel.Columns)
        {
            object value;
            if (column.Value.TypeFullName == typeof(int).FullName)
                value = i;
            else if (column.Value.TypeFullName == typeof(decimal?).FullName)
                value = i % 3 != 0 ? ((double)i / 2.1234) : null;
            else if (column.Value.TypeFullName == typeof(DateTime).FullName)
                value = DateTime.Now.AddMonths(i);
            else
                value = $"{column.Key}{i}";

            row.Add(column.Key, value);
        }

        return row;
    }).ToList();

I believe you got the point.

If I directly bind my controls with these objects, it's ok.

But if I serialize/deserialize them through a Http.PostAsJsonAsync(url) / ReadFromJsonAsync() of System.Text.Json, it's broken, because all my "objects" have became some "JsonElement". Of course I understand this behavior, now I have to deal with it.

So my question is : how can I deserialize these as regular .Net objects, given I have the type name in another property ?

I searched to create a custom JsonConverter, but that's a monstruous beast, with a lot of subclasses and internal/sealed class, so I was able to override it.

Alternatively, I did something else to "recompute" my dictionaries after deserialization.

Here's a part of the conversion method :

private IEnumerable<Dictionary<string, object>> JsonElementAsObjects()
{
    var liste = new List<Dictionary<string, object>>();
    foreach (var row in GridResult.Data)
    {
        var dictionary = new Dictionary<string, object>();

        foreach (var col in row)
        {
            var columnDefinition = Columns[col.Key];
            
            object value = null;

            if (col.Value != null)
            {
                value = GetObject((JsonElement) col.Value, columnDefinition);
            }
            dictionary.Add(col.Key, value);
        }

        liste.Add(dictionary);
    }

    return liste;
}

I tried to find a method in JsonElement / JsonValue to convert it to objects, but I only found a generic method (ConvertJsonElement<TypeToConvert>() in JsonValue<T> which doesn't really help, as my types are known only at runtime).

So I temporary extract some of its content, and adapted it to use runtime type :

private object GetObject(JsonElement element, ColumnDefinition columnDefinition)
{
    var typeToConvert = columnDefinition.GetColumnType();

    switch (element.ValueKind)
    {
        case JsonValueKind.Number:
            if (typeToConvert == typeof(int) || typeToConvert == typeof(int?))
            {
                return element.GetInt32();
            }

            if (typeToConvert == typeof(long) || typeToConvert == typeof(long?))
            {
                return element.GetInt64();
            }

            if (typeToConvert == typeof(double) || typeToConvert == typeof(double?))
            {
                return element.GetDouble();
            }

            if (typeToConvert == typeof(short) || typeToConvert == typeof(short?))
            {
                return element.GetInt16();
            }

            if (typeToConvert == typeof(decimal) || typeToConvert == typeof(decimal?))
            {
                return element.GetDecimal();
            }

            if (typeToConvert == typeof(byte) || typeToConvert == typeof(byte?))
            {
                return element.GetByte();
            }

            if (typeToConvert == typeof(float) || typeToConvert == typeof(float?))
            {
                return element.GetSingle();
            }

            if (typeToConvert == typeof(uint) || typeToConvert == typeof(uint?))
            {
                return element.GetUInt32();
            }

            if (typeToConvert == typeof(ushort) || typeToConvert == typeof(ushort?))
            {
                return element.GetUInt16();
            }

            if (typeToConvert == typeof(ulong) || typeToConvert == typeof(ulong?))
            {
                return element.GetUInt64();
            }

            if (typeToConvert == typeof(sbyte) || typeToConvert == typeof(sbyte?))
            {
                return element.GetSByte();
            }
            break;

        case JsonValueKind.String:
            if (typeToConvert == typeof(string))
            {
                return element.GetString()!;
            }

            if (typeToConvert == typeof(DateTime) || typeToConvert == typeof(DateTime?))
            {
                return element.GetDateTime();
            }

            if (typeToConvert == typeof(DateTimeOffset) || typeToConvert == typeof(DateTimeOffset?))
            {
                return element.GetDateTimeOffset();
            }

            if (typeToConvert == typeof(Guid) || typeToConvert == typeof(Guid?))
            {
                return element.GetGuid();
            }

            if (typeToConvert == typeof(char) || typeToConvert == typeof(char?))
            {
                string? str = element.GetString();
                Debug.Assert(str != null);
                if (str.Length == 1)
                {
                    return str[0];
                }
            }
            break;

        case JsonValueKind.True:
        case JsonValueKind.False:
            if (typeToConvert == typeof(bool) || typeToConvert == typeof(bool?))
            {
                return element.GetBoolean();
            }
            break;
    }

    return element;
}

It's working perfectly with this.

But having this is obviously an heavy useless post-process, with a lot of boilerplate code to maintain.

Could you please give me a cleaner solution to deserialize my data as "true objects", or at least to adapt them after deserialization like I did, but with a shorter code, using some hidden tricks in System.Text.Json ?

Thank you !

AFract
  • 8,868
  • 6
  • 48
  • 70
  • 1
    Maybe this can help, but not sure https://github.com/osexpert/GoreRemoting/blob/master/src/GoreRemoting.Serialization.Json/TypelessFormatter.cs. As example, the formatter is used in here https://github.com/osexpert/GoreRemoting/blob/master/src/GoreRemoting.Serialization.Json/JsonAdapter.cs – osexpert Jul 25 '23 at 18:24
  • 1
    `GetObject()` only works for primitive types. Are your actual types primitives, or arbitrary POCOs? – dbc Jul 25 '23 at 18:54
  • 2
    `Type.GetType(TypeFullName)` leaves you open to [Friday the 13th JSON attacks](https://www.blackhat.com/docs/us-17/thursday/us-17-Munoz-Friday-The-13th-JSON-Attacks-wp.pdf) type injection attacks that caused problems with Newtonsoft a while back. See e.g. [TypeNameHandling caution in Newtonsoft Json](https://stackoverflow.com/q/39565954) or [External json vulnerable because of Json.Net TypeNameHandling auto?](https://stackoverflow.com/q/49038055). You need to whitelist or otherwise sanitize your type names to prevent that. – dbc Jul 25 '23 at 18:59
  • @dbc I didn't explain much more in my post about my use case because it would have been to long and not relevant for my issue, but as I have said it's for databinding, so yes I just need to handle primitives types, not any POCO or complex class. Thank you. – AFract Jul 26 '23 at 15:03
  • @dbc thank you for this interesting story regarding GetType ! So far I was trying to make deserialization work as I need it, so I didn't take care of those security aspects. Anyway, in my case it's not really a concern because I have full control incoming JSON and what's done with it. But thank you for pointing this. – AFract Jul 26 '23 at 15:05

1 Answers1

2

Quick and dirty approach would be to do the interim deserialization (as you do) with UnknownTypeHandling set to JsonUnknownTypeHandling.JsonNode and then use non-generic JsonNode.Deserialize. Something like the following:

var res = JsonSerializer.Deserialize<DynamicDataGridResult>(jsonString, new JsonSerializerOptions
{
    UnknownTypeHandling = JsonUnknownTypeHandling.JsonNode
});

res = new DynamicDataGridResult
{
    Columns = res.Columns,
    Data = res.Data
        .Select(d => d
            .ToDictionary(
                d => d.Key,
                d =>(d.Value as JsonNode)?.Deserialize(res.Columns[d.Key].GetColumnType())))
        .ToList()
};

Or the same using JsonElement:

var res = JsonSerializer.Deserialize<DynamicDataGridResult>(jsonString);
res = new DynamicDataGridResult
{
    Columns = res.Columns,
    Data = res.Data
        .Select(d => d
            .ToDictionary(
                d => d.Key,
                d =>(d.Value as JsonElement?)?.Deserialize(res.Columns[d.Key].GetColumnType())))
        .ToList()
};
Guru Stron
  • 102,774
  • 10
  • 95
  • 132
  • You can use [`Deserialize()`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.deserialize?view=net-7.0#system-text-json-jsonserializer-deserialize(system-text-json-jsonelement-system-type-system-text-json-jsonserializeroptions)) on `JsonElement` as well. – dbc Jul 25 '23 at 18:54
  • @dbc not sure how I have missed that TBH =)) Thank you. – Guru Stron Jul 25 '23 at 18:55
  • Thank's a lot ! It's still not a perfect solution because it requires to deserialize twice (more or less), but at least it's much cleaner and shorter than my monstruous method to handle all types. I completely missed the (so simple and obvious) Deserialize method, I was too much looking for appropriate "Getxx / TryGetxx" methods. – AFract Jul 26 '23 at 15:11
  • @AFract was glad to help! – Guru Stron Jul 27 '23 at 00:07