C# deep/nested/recursive merge of dynamic/expando objects

4.7k Views Asked by At

I need to "merge" 2 dynamic objects in C#. All that I've found on stackexchange covered only non-recursive merging. But I am looking to something that does recursive or deep merging, very much the same like jQuery's $.extend(obj1, obj2) function.

Upon collision of two members, the following rules should apply:

  • If the types mismatch, an exception must be thrown and merge is aborted. Exception: obj2 Value maybe null, in this case the value & type of obj1 is used.
  • For trivial types (value types + string) obj1 values are always prefered
  • For non-trivial types, the following rules are applied:
    • IEnumerable & IEnumberables<T> are simply merged (maybe .Concat() ? )
    • IDictionary & IDictionary<TKey,TValue> are merged; obj1 keys have precedence upon collision
    • Expando & Expando[] types must be merged recursively, whereas Expando[] will always have same-type elements only
    • One can assume there are no Expando objects within Collections (IEnumerabe & IDictionary)
  • All other types can be discarded and need not be present in the resulting dynamic object

Here is an example of a possible merge:

dynamic DefaultConfig = new {
    BlacklistedDomains = new string[] { "domain1.com" },
    ExternalConfigFile = "blacklist.txt",
    UseSockets = new[] {
        new { IP = "127.0.0.1", Port = "80"},
        new { IP = "127.0.0.2", Port = "8080" }
    }
};

dynamic UserSpecifiedConfig = new {
    BlacklistedDomain = new string[] { "example1.com" },
    ExternalConfigFile = "C:\\my_blacklist.txt"
};

var result = Merge (UserSpecifiedConfig, DefaultConfig);
// result should now be equal to:
var result_equal = new {
    BlacklistedDomains = new string[] { "domain1.com", "example1.com" },
    ExternalConfigFile = "C:\\my_blacklist.txt",
    UseSockets = new[] {
        new { IP = "127.0.0.1", Port = "80"},
        new { IP = "127.0.0.2", Port = "8080" }
    }
};

Any ideas how to do this?

2

There are 2 best solutions below

0
On

Right, this is a bit longwinded but have a look. it's an implementation using Reflection.Emit.

Open issue for me is how to implement a ToString() override so that you can do a string comparison. Are these values coming from a config file or something? If they are in JSON Format you could do worse than use a JsonSerializer, I think. Depends on what you want.

You could use the Expando Object to get rid of the Reflection.Emit nonsense as well, at the bottom of the loop:

var result = new ExpandoObject();
var resultDict = result as IDictionary<string, object>;
foreach (string key in resVals.Keys)
{
    resultDict.Add(key, resVals[key]);
}
return result;

I can't see a way around the messy code for parsing the original object tree though, not immediately. I'd like to hear some other opinions on this. The DLR is relatively new ground for me.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
using System.Threading;

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            dynamic DefaultConfig = new
            {
                BlacklistedDomains = new string[] { "domain1.com" },
                ExternalConfigFile = "blacklist.txt",
                UseSockets = new[] { 
                    new { IP = "127.0.0.1", Port = "80" }, 
                    new { IP = "127.0.0.2", Port = "8080" } 
                }
            };

            dynamic UserSpecifiedConfig = new
            {
                BlacklistedDomains = new string[] { "example1.com" },
                ExternalConfigFile = "C:\\my_blacklist.txt"
            };

            var result = Merge(UserSpecifiedConfig, DefaultConfig);

            // result should now be equal to: 

            var result_equal = new
            {
                BlacklistedDomains = new string[] { "domain1.com", "example1.com" },
                ExternalConfigFile = "C:\\my_blacklist.txt",
                UseSockets = new[] {         
                    new { IP = "127.0.0.1", Port = "80"},         
                    new { IP = "127.0.0.2", Port = "8080" }     
                }
            };
            Debug.Assert(result.Equals(result_equal));
        }

        /// <summary>
        /// Merge the properties of two dynamic objects, taking the LHS as primary
        /// </summary>
        /// <param name="lhs"></param>
        /// <param name="rhs"></param>
        /// <returns></returns>
        static dynamic Merge(dynamic lhs, dynamic rhs)
        {
            // get the anonymous type definitions
            Type lhsType = ((Type)((dynamic)lhs).GetType());
            Type rhsType = ((Type)((dynamic)rhs).GetType());

            object result = new { };
            var resProps = new Dictionary<string, PropertyInfo>();
            var resVals = new Dictionary<string, object>();

            var lProps = lhsType.GetProperties().ToDictionary<PropertyInfo, string>(prop => prop.Name);
            var rProps = rhsType.GetProperties().ToDictionary<PropertyInfo, string>(prop => prop.Name); 


            foreach (string leftPropKey in lProps.Keys)
            {
                var lPropInfo = lProps[leftPropKey];
                resProps.Add(leftPropKey, lPropInfo);
                var lhsVal = Convert.ChangeType(lPropInfo.GetValue(lhs, null), lPropInfo.PropertyType);
                if (rProps.ContainsKey(leftPropKey))
                {
                    PropertyInfo rPropInfo;
                    rPropInfo = rProps[leftPropKey];
                    var rhsVal = Convert.ChangeType(rPropInfo.GetValue(rhs, null), rPropInfo.PropertyType);
                    object setVal = null;

                    if (lPropInfo.PropertyType.IsAnonymousType())
                    {
                        setVal = Merge(lhsVal, rhsVal);
                    }
                    else if (lPropInfo.PropertyType.IsArray)
                    {
                        var bound = ((Array) lhsVal).Length + ((Array) rhsVal).Length;
                        var cons = lPropInfo.PropertyType.GetConstructor(new Type[] { typeof(int) });
                        dynamic newArray = cons.Invoke(new object[] { bound });
                        //newArray = ((Array)lhsVal).Clone();
                        int i=0;
                        while (i < ((Array)lhsVal).Length)
                        {
                            newArray[i] = lhsVal[i];
                            i++;
                        }
                        while (i < bound)
                        {
                            newArray[i] = rhsVal[i - ((Array)lhsVal).Length];
                            i++;
                        }
                        setVal = newArray;
                    }
                    else
                    {
                        setVal = lhsVal == null ? rhsVal : lhsVal;
                    }
                    resVals.Add(leftPropKey, setVal);
                }
                else 
                {
                    resVals.Add(leftPropKey, lhsVal);
                }
            }
            foreach (string rightPropKey in rProps.Keys)
            {
                if (lProps.ContainsKey(rightPropKey) == false)
                {
                    PropertyInfo rPropInfo;
                    rPropInfo = rProps[rightPropKey];
                    var rhsVal = rPropInfo.GetValue(rhs, null);
                    resProps.Add(rightPropKey, rPropInfo);
                    resVals.Add(rightPropKey, rhsVal);
                }
            }

            Type resType = TypeExtensions.ToType(result.GetType(), resProps);

            result = Activator.CreateInstance(resType);

            foreach (string key in resVals.Keys)
            {
                var resInfo = resType.GetProperty(key);
                resInfo.SetValue(result, resVals[key], null);
            }
            return result;
        }
    }
}

public static class TypeExtensions
{
    public static Type ToType(Type type, Dictionary<string, PropertyInfo> properties)
    {
        AppDomain myDomain = Thread.GetDomain();
        Assembly asm = type.Assembly;
        AssemblyBuilder assemblyBuilder = 
            myDomain.DefineDynamicAssembly(
            asm.GetName(), 
            AssemblyBuilderAccess.Run
        );
        ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule(type.Module.Name);
        TypeBuilder typeBuilder = moduleBuilder.DefineType(type.Name,TypeAttributes.Public);

        foreach (string key in properties.Keys)
        {
            string propertyName = key;
            Type propertyType = properties[key].PropertyType;

            FieldBuilder fieldBuilder = typeBuilder.DefineField(
                "_" + propertyName,
                propertyType,
                FieldAttributes.Private
            );

            PropertyBuilder propertyBuilder = typeBuilder.DefineProperty(
                propertyName,
                PropertyAttributes.HasDefault,
                propertyType,
                new Type[] { }
            );
            // First, we'll define the behavior of the "get" acessor for the property as a method.
            MethodBuilder getMethodBuilder = typeBuilder.DefineMethod(
                "Get" + propertyName,
                MethodAttributes.Public,
                propertyType,
                new Type[] { }
            );

            ILGenerator getMethodIL = getMethodBuilder.GetILGenerator();

            getMethodIL.Emit(OpCodes.Ldarg_0);
            getMethodIL.Emit(OpCodes.Ldfld, fieldBuilder);
            getMethodIL.Emit(OpCodes.Ret);

            // Now, we'll define the behavior of the "set" accessor for the property.
            MethodBuilder setMethodBuilder = typeBuilder.DefineMethod(
                "Set" + propertyName,
                MethodAttributes.Public,
                null,
                new Type[] { propertyType }
            );

            ILGenerator custNameSetIL = setMethodBuilder.GetILGenerator();

            custNameSetIL.Emit(OpCodes.Ldarg_0);
            custNameSetIL.Emit(OpCodes.Ldarg_1);
            custNameSetIL.Emit(OpCodes.Stfld, fieldBuilder);
            custNameSetIL.Emit(OpCodes.Ret);

            // Last, we must map the two methods created above to our PropertyBuilder to 
            // their corresponding behaviors, "get" and "set" respectively. 
            propertyBuilder.SetGetMethod(getMethodBuilder);
            propertyBuilder.SetSetMethod(setMethodBuilder);
        }

        //MethodBuilder toStringMethodBuilder = typeBuilder.DefineMethod(
        //    "ToString",
        //    MethodAttributes.Public,
        //    typeof(string),
        //    new Type[] { }
        //);

        return typeBuilder.CreateType();
    }
    public static Boolean IsAnonymousType(this Type type)
    {
        Boolean hasCompilerGeneratedAttribute = type.GetCustomAttributes(
            typeof(CompilerGeneratedAttribute), false).Count() > 0;
        Boolean nameContainsAnonymousType =
            type.FullName.Contains("AnonymousType");
        Boolean isAnonymousType = hasCompilerGeneratedAttribute && nameContainsAnonymousType;
        return isAnonymousType;
    }
}
0
On

This works for me, but I'm sure it could be given some love and attention and look better. It does not include your type-check but that would be rather trivial to add. So while this is not a perfect answer, I hope it can get you closer to a solution.

Subsequent calls to DynamicIntoExpando(...) will keep appending and overwriting new and existing values to the existing Source structure. You can call it as many times as you need to. The function MergeDynamic() illustrates how two dynamics are merged into one ExpandoObject.

The code basically iterates over the dynamic value, checks the type, and merge appropriately and recursively to any depth.

I wrapped it in a helper class for my own purposes.

using System.Dynamic; // For ExpandoObject
...

public static class DynamicHelper
{

    // We expect inputs to be of type IDictionary
    public static ExpandoObject MergeDynamic(dynamic Source, dynamic Additional)
    {
        ExpandoObject Result = new ExpandoObject();

        // First copy 'source' to Result
        DynamicIntoExpando(Result, Source);

        // Then copy additional fields, boldy overwriting the source as needed
        DynamicIntoExpando(Result, Additional);

        // Done
        return Result;
    }

    public static void DynamicIntoExpando(ExpandoObject Result, dynamic Source, string Key = null)
    {
        // Cast it for ease of use.
        var R = Result as IDictionary<string, dynamic>;

        if (Source is IDictionary<string, dynamic>)
        {
            var S = Source as IDictionary<string, dynamic>;

            ExpandoObject NewDict = new ExpandoObject();
            if (Key == null)
            {
                NewDict = Result;
            }
            else if (R.ContainsKey(Key))
            {
                // Already exists, overwrite
                NewDict = R[Key];
            }
            var ND = NewDict as IDictionary<string, dynamic>;

            foreach (string key in S.Keys)
            {
                ExpandoObject NewDictEntry = new ExpandoObject();
                var NDE = NewDictEntry as IDictionary<string, dynamic>;
                if (ND.ContainsKey(key))
                {
                    NDE[key] = ND[key];
                }
                else if (R.ContainsKey(key))
                {
                    NDE[key] = R[key];
                }

                DynamicIntoExpando(NewDictEntry, S[key], key);
                if(!R.ContainsKey(key)) {
                    ND[key] = ((IDictionary<string, dynamic>)NewDictEntry)[key];
                }
            }
            if (Key == null)
            {
                R = NewDict;
            }
            else if (!R.ContainsKey(Key))
            {
                R.Add(Key, NewDict);
            }
        }
        else if (Source is IList<dynamic>)
        {
            var S = Source as IList<dynamic>;
            List<ExpandoObject> NewList = new List<ExpandoObject>();
            if (Key != null && R.ContainsKey(Key))
            {
                // Already exists, overwrite
                NewList = (List<ExpandoObject>)R[Key];
            }
            foreach (dynamic D in S)
            {
                ExpandoObject ListEntry = new ExpandoObject();
                DynamicIntoExpando(ListEntry, D);
                //  in this case we have to compare the ListEntry to existing entries and on

                NewList.Add(ListEntry);
            }
            if (Key != null && !R.ContainsKey(Key))
            {
                R[Key] = NewList.Distinct().ToList(); 
            }
        }
        else
        {
            R[Key] = Source;
        }
    }
}