Because my original question was lacking many details, I have been advised to ask a new question.
I will repeat the important parts of the original question and add examples etc to hopefully make it clear.
For my blazor library which is a modification of this awesome library I have to convert an ExpandoObject
into a Dictionary<string, object>
since ExpandoObject
s aren't serialized properly in the newest preview versions of dotnet-core. See my question related to this for more details.
For this new question I have already improved my code with recommendations from this and this answer on my previous question.
This is my current (and hopefully already better) approach. I have added summaries to more clearly show my intent but I'm sure these could be improved as well since I'm not very good at documenting my code.
Also worth mentioning if it's not clear, these changes did not affect the program and everything still works.
/// <summary>
/// This method is specifically used to convert an <see cref="ExpandoObject"/> with a Tree structure to a <see cref="Dictionary{string, object}"/>.
/// </summary>
/// <param name="expando">The <see cref="ExpandoObject"/> to convert</param>
/// <returns>The fully converted <see cref="ExpandoObject"/></returns>
private static Dictionary<string, object> ConvertExpandoObjectToDictionary(ExpandoObject expando) => RecursivelyConvertIDictToDict(expando);
/// <summary>
/// This method takes an <see cref="IDictionary{string, object}"/> and recursively converts it to a <see cref="Dictionary{string, object}"/>.
/// The idea is that every <see cref="IDictionary{string, object}"/> in the tree will be of type <see cref="Dictionary{string, object}"/> instead of some other implementation like <see cref="ExpandoObject"/>.
/// </summary>
/// <param name="value">The <see cref="IDictionary{string, object}"/> to convert</param>
/// <returns>The fully converted <see cref="Dictionary{string, object}"/></returns>
private static Dictionary<string, object> RecursivelyConvertIDictToDict(IDictionary<string, object> value) =>
value.ToDictionary(
keySelector => keySelector.Key,
elementSelector =>
{
// if it's another IDict just go through it recursively
if (elementSelector.Value is IDictionary<string, object> dict)
{
return RecursivelyConvertIDictToDict(dict);
}
// if it's an IEnumerable check each element
if (elementSelector.Value is IEnumerable<object> list)
{
// go through all objects in the list
// if the object is an IDict -> convert it
// if not keep it as is
return list
.Select(o => o is IDictionary<string, object>
? RecursivelyConvertIDictToDict((IDictionary<string, object>)o)
: o
);
}
// neither an IDict nor an IEnumerable -> it's fine to just return the value it has
return elementSelector.Value;
}
);
Something I didn't mention in detail is why I need this conversion in the first place. This is also the reason this answer probably wont help :/
I'll explain it with a simplified example:
public void Demo()
{
SomeConfig config = new SomeConfig
{
Options = new SomeOptions // it can contain complex types
{
SomeInt = 2, // it can contain primative types
SomeString = null,
Axes = new List<Axis> // it can contain complex lists
{
new Axis(),
new Axis
{
SomeString = "axisString"
}
}
},
Data = new SomeData
{
Data = new List<int> { 1, 2, 3, 4, 5 }, // it can contain primative lists
SomeString = "asdf",
SomeStringEnum = MyStringEnum.Test // it can contain objects with custom parsing (for JSON.NET, not the parsing that's done when invoking the JS sadly
}
};
// now there are three options for invoking the javascript (I invoke it using IJSRuntime.InvokeAsync)
// this does not work because there are still nulls and the StringEnum will be parsed as an object instead of string (
// json.net parses it correctly because of the custom converter but .net does not as it just sees an object)
InvokeJavascript(config);
// the nulls are gone and the StringEnum is parsed correctly but
// this does not work because .Net (NOT JSON.NET) has to convert the parameter to json which is not possible if the object is an ExpandoObject
InvokeJavascript(StripNulls(config));
// this does work because .Net can serialize a Dictionary but not an ExpandoObject.
// the nulls are still gone and the StringEnum is parsed correctly
InvokeJavascript(ConvertExpandoObjectToDictionary(StripNulls(config)));
}
/// <summary>
/// Returns an object that is equivalent to the given parameter but without any null members.
/// <para>This method is necessary because of the custom parsing for string enums and because for server-side blazor projects,
/// the interop doesn't work if there are null values (no idea why, this really should be fixed sometime in the future)</para>
/// </summary>
private static ExpandoObject StripNulls(SomeConfig chartConfig)
{
// Serializing with the custom serializer settings remove null members
// this cleanChartConfigStr doesn't contain a single null value and also the StringEnums are parsed as strings instead of objects because I use JSON.NET here
string cleanChartConfigStr = JsonConvert.SerializeObject(chartConfig, JsonSerializerSettings);
// Get back an ExpandoObject with the clean config - having an ExpandoObject allows us to add/replace members regardless of type
// which is necessary for preserving the DotNetObjectRefs (see below). Also if it were to be parsed back to the original Type (SomeConfig)
// the null values would return (and the string enums would throw errors since I've only implemented writing json but that could be fixed)
dynamic clearConfigExpando = JsonConvert.DeserializeObject<ExpandoObject>(cleanChartConfigStr, new ExpandoObjectConverter());
/*
* There are DotNetObjectRefs in the config which are manually being restored here and assigned to the right property in the dynamic config
*/
return clearConfigExpando;
}
// serializer settings for json.net to ignore all the null values and use the CamelCaseNamingStrategy
private static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy(true, false)
}
};
I can show you the difference when looking at the json that get produced when invoking the javascript with different objects:
Directly invoking the javascript with the SomeConfig
instance from the demo results in this json code being used in the javascript:
{
"options": {
"someInt": 2,
"someString": null,
"axes": [
{
"someString": null
},
{
"someString": "axisString"
}
]
},
"data": {
"data": [
1,
2,
3,
4,
5
],
"someString": "asdf",
"someStringEnum": {}
}
}
First parsing it with custom parsing (using JSON.NET), then reading it back as ExpandoObject
(using JSON.NET) and converting that to a Dictionary<string, object>
results in the following json being used:
{
"options": {
"someInt": 2,
"axes": [
{},
{
"someString": "axisString"
}
]
},
"data": {
"data": [
1,
2,
3,
4,
5
],
"someString": "asdf",
"someStringEnum": "someTestThing"
}
}
Note that both of these json string were not produced by json.net but by .net itself when invoking the javascript using IJSRuntime.InvokeAsync
.
The json of the second option is in this example exactly the same as the intermediate json cleanChartConfigStr
in the StripNulls
method. This cleanChartConfigStr
does not contain the functions and DotNetObjectRefs
which are added to the ExpandoObject
later on in the StripNulls
method. When using JSON.stringify
to print the json-string of that ExpandoObject
(that's the json-string I showed you), you won't see those functions/DotNetObjectRefs
either but this time because JSON.stringify
doesn't print them, not because there are non-existent.
I mention this because otherwise you might think that the cleanChartConfigStr
as json-object would be equal to the json-object produced by the ExpandoObject
which is not true (because of those functions and DotNetObjectRefs
).
I feel like this part was confusing, I hope you understand what's going on.
Here are the remaining classes needed for the code to make sense:
Because you will need the jsRuntime and the blazor project blabla to compile, I have created a small demo-branch on my github. It's ugly code and no, this will not be used anymore it's just so you can try it yourself if you want. The 3 json-strings (the two I showed + the intermediate one) are printed to the console (cmd, not browser) when you access the homepage or one of the chart-pages. Keep in mind that you'll need the newest version of .net core 3 (preview6) installed to run it. I think VS 19 preview is also required.
class SomeConfig
{
public SomeOptions Options { get; set; }
public SomeData Data { get; set; }
}
class SomeOptions
{
public int SomeInt { get; set; }
public string SomeString { get; set; }
public List<Axis> Axes { get; set; }
}
class SomeData
{
public List<int> Data { get; set; }
public string SomeString { get; set; }
public MyStringEnum SomeStringEnum { get; set; }
}
class Axis
{
public string SomeString { get; set; }
}
[JsonConverter(typeof(JsonStringEnumConverter))]
class MyStringEnum
{
public static MyStringEnum Test => new MyStringEnum("someTestThing");
private readonly string _value;
private MyStringEnum(string stringRep) => _value = stringRep;
public override string ToString() => _value;
}
class JsonStringEnumConverter : JsonConverter<MyStringEnum>
{
public sealed override bool CanRead => false;
public sealed override bool CanWrite => true;
public sealed override MyStringEnum ReadJson(JsonReader reader, Type objectType, MyStringEnum existingValue, bool hasExistingValue, JsonSerializer serializer)
{
throw new NotImplementedException("Don't use me to read JSON");
}
public override void WriteJson(JsonWriter writer, MyStringEnum value, JsonSerializer serializer)
{
// ToString was overwritten by StringEnum -> safe to just print the string representation
writer.WriteValue(value.ToString());
}
}
Now, I have put a lot of time and effort in this question and I hope now there's enough detail. As already mentioned, the real code (not this small demo example) is available on github. The most important file for this question would be ChartJsInterop.cs.
Once again I'm mostly interested in the recursive solution to convert those ExpandoObject
s. However I'm happy for any critisism so please also mention every small thing that bugs you :)
EDIT:
After a discussion with @t3chb0t I feel like I have to mention this:
The whole exchange is in the comments.
I'm not at all saying that using this ExpandoObject is good. It feels like a hack because it is one. However I've not found a way to remove it as I have no idea how you would do the custom serialization for .NET (instead of JSON.NET) and also how you would remove the null values. If you have an idea for that which doesn't use ExpandoObject I'd love to hear it. The ExpandoObject does not at all interfere with the typesafety and is 100% only used for the serialization (particularly the removing of the null values and adapting the custom enum values). It's never exposed publicly in any way.
If you have an idea to get rid of the ExpandoObject
feel free to tell me but don't just call me out for using ExpandoObject
without understanding the situation.
It is used as little as possible and only has to do with some serialization behind the scenes, the developer who uses the library will never have anything to do with an ExpandoObject
and even if you'd develop code for this library the chance is extremely small that you would need to do something with ExpandoObject
. It does not hurt the typesafety of the config because that's a typesafe class and has nothing to do with ExpandoObject
until the serialization.
I hope you understand. If you don't, let me know what you don't understand and look at the repository. There are parts from our exchange which point out some important files:
Here's one of the usecases (hint there's absolutely no
ExpandoObject
and it's entirely typesafe to use). Here's the Config that is being used, once again not a singleExpandoObject
down the tree.Here is the first and only use of
ExpandoObject
. This link is also in the question.
SomeConfig
model is hypothetical and we can only guess how it really looks like. I won't review it because you explain it with a simplified example so this is my simplified answer ;-] Posting the real model would make this question much more useful. \$\endgroup\$BaseConfig<TData, TOptions> where TData : BaseData where TOptions : BaseOptions
and the same for options and data with base classes and generics etc. I can put that into the question if that's needed but it's going to be a lot and also you can find that on my Github if you want to look at it. Should I really put that in the question (as soon as I have time so not right now)? \$\endgroup\$JsonSerializer
which is used byIJSRuntime
. HoweverIJSRuntime
doesn't support using your own options so there is no way of tellingIJSRuntime
that it should discard null values. If you want to be up-to-date with this issue please look at the issue (and other issues of my repos) I've linked in the comment above. I will come back to this question and answer it myself as soon as I have a the final solution. \$\endgroup\$