Photo by Kenan Reed

The problem

Because of (third party) reasons I needed to transform some objects to a flat Dictionary<string, string>.

Example

Given the following class...

public class MyClass
{
    public bool Boolean { get; set; }
    public string String { get; set; }
    public Guid Guid { get; set; }
    public int Integer { get; set; }
    public IEnumerable<MyClass> MyClasses { get; set; }
    public IEnumerable<string> Strings { get; set; }
    public MyNestedClass MyNestedClass { get; set; }
}

public class MyNestedClass
{
    public bool Boolean { get; set; }
    public string String { get; set; }
    public Guid Guid { get; set; }
    public int Integer { get; set; }
}

I needed to transform this...

var myClass = new MyClass
{
    Boolean = true,
    Guid = Guid.NewGuid(),
    Integer = 100,
    String = "string"
};

...to this:

...
var result = sut.Execute(myClass, prefix: "Data");

result.ShouldContainKeyAndValue($"Data.Boolean", "true");
result.ShouldContainKeyAndValue($"Data.Guid", myClass.Guid.ToString());
result.ShouldContainKeyAndValue($"Data.Integer", myClass.Integer.ToString());
result.ShouldContainKeyAndValue($"Data.String", myClass.String);

I also needed to support nested classes...

var myClass = new MyClass
{
    Boolean = true,
    Guid = Guid.NewGuid(),
    Integer = 100,
    String = "string",
    MyNestedClass = new 
    {
        Boolean = true,
        Guid = Guid.NewGuid(),
        Integer = 100,
        String = "string"
    }
};

var result = sut.Execute(myClass, prefix: "Data");

result.ShouldContainKeyAndValue("Data.MyNestedClass.Boolean", myClass.MyNestedClass.Boolean.ToString().ToLower());
result.ShouldContainKeyAndValue("Data.MyNestedClass.Guid", myClass.MyNestedClass.Guid.ToString());
result.ShouldContainKeyAndValue("Data.MyNestedClass.Integer", myClass.MyNestedClass.Integer.ToString());
result.ShouldContainKeyAndValue("Data.MyNestedClass.String", myClass.MyNestedClass.String);

And Enumerables

var myClass = new MyClass
{
    Boolean = true,
    Guid = Guid.NewGuid(),
    Integer = 100,
    String = "string",
    MyClasses = new List<MyClass>
    {
        new MyClass
        {
            Boolean = true,
            Guid = Guid.NewGuid(),
            Integer = 100,
            String = "string"
        },
        new MyClass
        {
            Boolean = true,
            Guid = Guid.NewGuid(),
            Integer = 100,
            String = "string"
        }
    }
};

var result = sut.Execute(myClass, prefix: "Data");
var myClassesList = myClass.MyClasses.ToList();

result.ShouldContainKeyAndValue("Data.MyClasses[0].Boolean", myClassesList[0].Boolean.ToString().ToLower());
result.ShouldContainKeyAndValue("Data.MyClasses[0].Guid", myClassesList[0].Guid.ToString());
result.ShouldContainKeyAndValue("Data.MyClasses[0].Integer", myClassesList[0].Integer.ToString());
result.ShouldContainKeyAndValue("Data.MyClasses[0].String", myClassesList[0].String);
result.ShouldContainKeyAndValue("Data.MyClasses[1].Boolean", myClassesList[1].Boolean.ToString().ToLower());
result.ShouldContainKeyAndValue("Data.MyClasses[1].Guid", myClassesList[1].Guid.ToString());
result.ShouldContainKeyAndValue("Data.MyClasses[1].Integer", myClassesList[1].Integer.ToString());
result.ShouldContainKeyAndValue("Data.MyClasses[1].String", myClassesList[1].String);

Implementations

I created the following interface and got to work.

public interface IFlatDictionaryProvider
{
    Dictionary<string, string> Execute(object @object, string prefix = "");
}

Extension methods used in the different implementations

internal static class Extensions
{
    internal static bool IsValueTypeOrString(this Type type)
    {
        return type.IsValueType || type == typeof(string);
    }

    internal static string ToStringValueType(this object value)
    {
        return value switch
        {
            DateTime dateTime => dateTime.ToString("o"),
            bool boolean => boolean.ToStringLowerCase(),
            _ => value.ToString()
        };
    }

    internal static bool IsIEnumerable(this Type type)
    {
        return type.IsAssignableTo(typeof(IEnumerable));
    }

    internal static string ToStringLowerCase(this bool boolean)
    {
        return boolean ? "true" : "false";
    }
}

"Hard coded" implementation

First I wanted to create something that made my tests go green, so I started with the following "hard coded" implementation. Bare with me here, this is just the beginning... :)

public class HardCodedImplementation : IFlatDictionaryProvider
{
    public Dictionary<string, string> Execute(object @object, string prefix = "")
    {
        var myClass = (MyClass)@object;
        var dictionary = new Dictionary<string, string>
        {
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.Boolean)}" , myClass.Boolean.ToString().ToLower()},
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.Integer)}", myClass.Integer.ToString()},
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.Guid)}", myClass.Guid.ToString()},
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.String)}", myClass.String},
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyNestedClass)}.{nameof(myClass.MyNestedClass.Boolean)}", myClass.MyNestedClass.Boolean.ToString().ToLower()},
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyNestedClass)}.{nameof(myClass.MyNestedClass.Integer)}", myClass.MyNestedClass.Integer.ToString()},
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyNestedClass)}.{nameof(myClass.MyNestedClass.Guid)}", myClass.MyNestedClass.Guid.ToString()},
            { $"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyNestedClass)}.{nameof(myClass.MyNestedClass.String)}", myClass.MyNestedClass.String}
        };

        var counter = 0;

        foreach (var @string in myClass?.Strings ?? Array.Empty<string>())
        {
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.Strings)}[{counter++}]", @string);
        }

        counter = 0;
        foreach (var myClassItem in myClass.MyClasses)
        {
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.Boolean)}", myClassItem.Boolean.ToString().ToLower());
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.Integer)}", myClassItem.Integer.ToString());
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.Guid)}", myClassItem.Guid.ToString());
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.String)}", myClassItem.String);
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.MyNestedClass)}.{nameof(myClassItem.MyNestedClass.Boolean)}", myClassItem.MyNestedClass.Boolean.ToString().ToLower());
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.MyNestedClass)}.{nameof(myClassItem.MyNestedClass.Integer)}", myClassItem.MyNestedClass.Integer.ToString());
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.MyNestedClass)}.{nameof(myClassItem.MyNestedClass.Guid)}", myClassItem.MyNestedClass.Guid.ToString());
            dictionary.Add($"{(string.IsNullOrWhiteSpace(prefix) ? string.Empty : $"{prefix}.")}{nameof(myClass.MyClasses)}[{counter}].{nameof(myClassItem.MyNestedClass)}.{nameof(myClassItem.MyNestedClass.String)}", myClassItem.MyNestedClass.String);
            counter++;
        }

        return dictionary;
    }
}

So, obviously, this is not a good solution to the problem at all. It only supports MyClass and everytime a new property gets added/removed I will need to update this code. And let's not even talk about all the nameof and string shenanigans...

Implementation1

This was my first attempt of a more dynamic solution.

  1. Get all properties for the passed in object.
  2. Loop through all properties.
  3. Get the property value using reflection.
  4. If the value is a value type or a string, call .ToString() on it and add it to the dictionary.
  5. If not, check if it's an IEnumerable.
    If IEnumerable -> loop through the items and call Flatten recursively.
    If not -> call Flatten recursively.
public class Implementation1 : IFlatDictionaryProvider
{
    public Dictionary<string, string> Execute(object @object, string prefix = "")
    {
        var dictionary = new Dictionary<string, string>();
        Flatten(dictionary, @object, prefix);
        return dictionary;
    }

    private static void Flatten(
        IDictionary<string, string> dictionary,
        object source,
        string name)
    {
        var properties = source.GetType().GetProperties().Where(x => x.CanRead);
        foreach (var property in properties)
        {
            var key = string.IsNullOrWhiteSpace(name) ? property.Name : $"{name}.{property.Name}";
            var value = property.GetValue(source, null);

            if (value == null)
            {
                dictionary[key] = null;
                continue;
            }

            if (property.PropertyType.IsValueTypeOrString())
            {
                dictionary[key] = value.ToStringValueType();
            }
            else
            {
                if (value is IEnumerable enumerable)
                {
                    var counter = 0;
                    foreach (var item in enumerable)
                    {
                        var itemKey = $"{key}[{counter++}]";
                        if (item.GetType().IsValueTypeOrString())
                        {
                            dictionary.Add(itemKey, item.ToStringValueType());
                        }
                        else
                        {
                            Flatten(dictionary, item, itemKey);
                        }
                    }
                }
                else
                {
                    Flatten(dictionary, value, key);
                }
            }
        }
    }
}

Implementation2

This is more or less the same implementation as Implementation1, the only difference is that I'm caching the properties per Type to avoid doing the GetProperties() call more than once.

public class Implementation2 : IFlatDictionaryProvider
{
    private static readonly ConcurrentDictionary<Type, PropertyInfo[]> CachedTypeProperties;

    static Implementation2()
    {
        CachedTypeProperties = new ConcurrentDictionary<Type, PropertyInfo[]>();
    }

    public Dictionary<string, string> Execute(object @object, string prefix = "")
    {
        var dictionary = new Dictionary<string, string>();
        Flatten(dictionary, @object, prefix);
        return dictionary;
    }

    private static void Flatten(
        IDictionary<string, string> dictionary,
        object source,
        string name)
    {
        var properties = GetProperties(source.GetType());
        foreach (var property in properties)
        {
            var key = string.IsNullOrWhiteSpace(name) ? property.Name : $"{name}.{property.Name}";
            var value = property.GetValue(source, null);

            if (value == null)
            {
                dictionary[key] = null;
                continue;
            }

            if (property.PropertyType.IsValueTypeOrString())
            {
                dictionary[key] = value.ToStringValueType();
            }
            else
            {
                if (value is IEnumerable enumerable)
                {
                    var counter = 0;
                    foreach (var item in enumerable)
                    {
                        var itemKey = $"{key}[{counter++}]";
                        if (item.GetType().IsValueTypeOrString())
                        {
                            dictionary.Add(itemKey, item.ToStringValueType());
                        }
                        else
                        {
                            Flatten(dictionary, item, itemKey);
                        }
                    }
                }
                else
                {
                    Flatten(dictionary, value, key);
                }
            }
        }
    }

    private static IEnumerable<PropertyInfo> GetProperties(Type type)
    {
        if (CachedTypeProperties.TryGetValue(type, out var result))
        {
            return result;
        }

        var properties = type.GetProperties().Where(x => x.CanRead).ToArray();
        CachedTypeProperties.TryAdd(type, properties);
        return properties;
    }
}

Implementation3

The first two implementations solved the problem and all my tests were green, nice. However, I felt that I could do better. There were still a bunch of reflection calls that I wanted to avoid, if possible.

My idea was to cache the properties getter methods using a compiled lambda. I've done that before to solve similar problems when performance was important.

Something like this:

  1. Get all the properties for the type
    A Check if they are cached, if so, return them
    B They are not cached, get them and populate the cache with the PropertyInfo and the getter method for that property.
  2. Loop through all properties
  3. Get the value of the property using the getter method.
  4. If the value is a value type or a string, call .ToString() on it.
  5. If not, check if it's an IEnumerable.
    If IEnumerable -> loop through the items and call ExecuteInternal recursively.
    If not -> call ExecuteInternal recursively.
public class Implementation3 : IFlatDictionaryProvider
{
    private static readonly ConcurrentDictionary<Type, Dictionary<PropertyInfo, Func<object, object>>> CachedProperties;

    static Implementation3()
    {
        CachedProperties = new ConcurrentDictionary<Type, Dictionary<PropertyInfo, Func<object, object>>>();
    }

    public Dictionary<string, string> Execute(object @object, string prefix = "")
    {
        return ExecuteInternal(@object, prefix: prefix);
    }

    private static Dictionary<string, string> ExecuteInternal(
        object @object,
        Dictionary<string, string> dictionary = default,
        string prefix = "")
    {
        dictionary ??= new Dictionary<string, string>();
        var type = @object.GetType();
        var properties = GetProperties(type);

        foreach (var (property, getter) in properties)
        {
            var key = string.IsNullOrWhiteSpace(prefix) ? property.Name : $"{prefix}.{property.Name}";
            var value = getter(@object);

            if (value == null)
            {
                dictionary.Add(key, null);
                continue;
            }

            if (property.PropertyType.IsValueTypeOrString())
            {
                dictionary.Add(key, value.ToStringValueType());
            }
            else
            {
                if (value is IEnumerable enumerable)
                {
                    var counter = 0;
                    foreach (var item in enumerable)
                    {
                        var itemKey = $"{key}[{counter++}]";
                        var itemType = item.GetType();
                        if (itemType.IsValueTypeOrString())
                        {
                            dictionary.Add(itemKey, item.ToStringValueType());
                        }
                        else
                        {
                            ExecuteInternal(item, dictionary, itemKey);
                        }
                    }
                }
                else
                {
                    ExecuteInternal(value, dictionary, key);
                }
            }
        }

        return dictionary;
    }

    private static Dictionary<PropertyInfo, Func<object, object>> GetProperties(Type type)
    {
        if (CachedProperties.TryGetValue(type, out var properties))
        {
            return properties;
        }

        CacheProperties(type);
        return CachedProperties[type];
    }

    private static void CacheProperties(Type type)
    {
        if (CachedProperties.ContainsKey(type))
        {
            return;
        }

        CachedProperties[type] = new Dictionary<PropertyInfo, Func<object, object>>();
        var properties = type.GetProperties().Where(x => x.CanRead);
        foreach (var propertyInfo in properties)
        {
            var getter = CompilePropertyGetter(propertyInfo);
            CachedProperties[type].Add(propertyInfo, getter);
            if (!propertyInfo.PropertyType.IsValueTypeOrString())
            {
                if (propertyInfo.PropertyType.IsIEnumerable())
                {
                    var types = propertyInfo.PropertyType.GetGenericArguments();
                    foreach (var genericType in types)
                    {
                        if (!genericType.IsValueTypeOrString())
                        {
                            CacheProperties(genericType);
                        }
                    }
                }
                else
                {
                    CacheProperties(propertyInfo.PropertyType);
                }
            }
        }
    }

    // Inspired by Zanid Haytam
    // https://blog.zhaytam.com/2020/11/17/expression-trees-property-getter/
    private static Func<object, object> CompilePropertyGetter(PropertyInfo property)
    {
        var objectType = typeof(object);
        var objectParameter = Expression.Parameter(objectType);
        var castExpression = Expression.TypeAs(objectParameter, property.DeclaringType);
        var convertExpression = Expression.Convert(
            Expression.Property(castExpression, property),
            objectType);
        return Expression.Lambda<Func<object, object>>(
            convertExpression,
            objectParameter).Compile();
    }
}

The interesting stuff happens in the CacheProperties and CompilePropertyGetter methods, let's break them down.

CacheProperties

private static void CacheProperties(Type type)
{
    if (CachedProperties.ContainsKey(type))
    {
        return;
    }

    CachedProperties[type] = new Dictionary<PropertyInfo, Func<object, object>>();
    // Get all the properties with reflection
    var properties = type.GetProperties().Where(x => x.CanRead);
    foreach (var propertyInfo in properties)
    {
        // Create a delegate for the property getter method
        var getter = CompilePropertyGetter(propertyInfo);
        // Cache the delegate
        CachedProperties[type].Add(propertyInfo, getter);
        // If it's not a string or value type...
        if (!propertyInfo.PropertyType.IsValueTypeOrString())
        {
            if (propertyInfo.PropertyType.IsIEnumerable())
            {
                // Get all types for the IEnumerable
                var types = propertyInfo.PropertyType.GetGenericArguments();
                foreach (var genericType in types)
                {
                    // If it's a "reference type", cache the properties for said type
                    if (!genericType.IsValueTypeOrString())
                    {
                        CacheProperties(genericType);
                    }
                }
            }
            else
            {
                // It's a reference type, cache the properties for said type
                CacheProperties(propertyInfo.PropertyType);
            }
        }
    }
}

I'm caching all properties of the type together with the property getter method. If a property is a reference type, I recursively call the CacheProperties method for that type so that I cache the properties for that type as well.

CompilePropertyGetter

private static Func<object, object> CompilePropertyGetter(PropertyInfo property)
{
    var objectType = typeof(object);
    // This is the type that we will pass to the delegate (object)
    var objectParameter = Expression.Parameter(objectType);
    // Casts the passed in object to the properties type
    var castExpression = Expression.TypeAs(objectParameter, property.DeclaringType);
    // Gets the value from the property and converts it to object
    var convertExpression = Expression.Convert(
        Expression.Property(typeAsExpression, property),
        objectType);

    // Creates a compiled lambda that we will cache.
    return Expression.Lambda<Func<object, object>>(
        convertExpression,
        objectParameter).Compile();
}

Benchmarks


BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
AMD Ryzen 9 3900X, 1 CPU, 24 logical and 12 physical cores
.NET Core SDK=6.0.100-preview.3.21202.5
  [Host]        : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT
  .NET Core 5.0 : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT

Job=.NET Core 5.0  Runtime=.NET Core 5.0  


|                           Method |     Mean |     Error |    StdDev | Ratio |  Gen 0 |  Gen 1 | Gen 2 | Allocated |
|--------------------------------- |---------:|----------:|----------:|------:|-------:|-------:|------:|----------:|
|         Implementation1Benchmark | 9.243 μs | 0.0620 μs | 0.0580 μs |  1.00 | 0.7324 | 0.0153 |     - |   5.98 KB |
|         Implementation2Benchmark | 8.139 μs | 0.0494 μs | 0.0462 μs |  0.88 | 0.6714 | 0.0153 |     - |   5.49 KB |
|         Implementation3Benchmark | 4.320 μs | 0.0222 μs | 0.0173 μs |  0.47 | 0.6485 | 0.0153 |     - |    5.3 KB |
| ImplementationHardCodedBenchmark | 4.582 μs | 0.0302 μs | 0.0252 μs |  0.50 | 0.6485 | 0.0153 |     - |   5.34 KB |

By caching the properties in Implementation2 we can see that the allocations was reduced and it also performed a bit faster, no suprises there.

Implementation3 allocates less AND runs faster than the "hard coded" implementation...let's call that a success shall we? :)

A complete solution (with tests) can be found at GitHub