-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Add support for non-string Tkey on Dictionary #32909
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
| } | ||
| else | ||
| { | ||
| state.Current.PolymorphicJsonPropertyInfo = state.Current.DeclaredJsonPropertyInfo!.RuntimeClassInfo.ElementClassInfo!.PolicyProperty; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@steveharter This was causing me problems since it causes that the JsonClassInfo takes its value from PolymorphicJsonPropertyInfo when processing the JsonExtensionData dictionary, I removed the line and ran the tests and nothing broke, so maybe you might know better what was the purpose behind this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cc @layomia
|
A reminder: it is not safe to deserialize a hash bucket-style collection (e.g., This shouldn't prevent a "please allow me to deserialize my desired dictionary type" feature from going through. But:
|
... Which means that a dictionary taking a (correctly implemented) IEqualityComparer for other types should also be safe. Side note: should we look into providing a wrapping comparer to perform randomized hashing? |
|
Yes, it's because |
...ries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs
Show resolved
Hide resolved
| } | ||
|
|
||
| [Fact] | ||
| public static void TestNotSuportedExceptionIsThrown() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this PR, NotSupportedExceptions are less often thrown since I don't check for the dictionary being supported until we actually call the dictionary KeyConverter. Is this behavior acceptable?
@steveharter Does your changes in #32669 related to delayed initialization also affect this behavior?
I can see that I need to fix this to avoid behavior changes but I just wanted to point this out since maybe this can be an acceptable or reasonable change.
The following table shows how this behave on master vs the feature branch.
private class UnsupportedDictionaryWrapper
{
public Dictionary<int[], int> Dictionary { get; set; }
}
private class UnsupportedDictionaryWrapper_Wrapper
{
public UnsupportedDictionaryWrapper Wrapper { get; set; }
}Throws NotSupportedException?
| Type | JSON | Branch: master | Branch: feature |
|---|---|---|---|
| Dictionary<int[], int> | yes | yes | |
| Dictionary<int[], int> | {} | yes | yes |
| Dictionary<int[], int> | null | yes | no |
| UnsupportedDictionaryWrapper | yes | no | |
| UnsupportedDictionaryWrapper | {} | yes | no |
| UnsupportedDictionaryWrapper | null | yes | no |
| UnsupportedDictionaryWrapper | {"Dictionary":{}} | yes | yes |
| UnsupportedDictionaryWrapper | {"Dictionary":null} | yes | no |
| UnsupportedDictionaryWrapper_Wrapper | no | no | |
| UnsupportedDictionaryWrapper_Wrapper | {} | no | no |
| UnsupportedDictionaryWrapper_Wrapper | null | no | no |
| UnsupportedDictionaryWrapper_Wrapper | {"Wrapper":{}} | yes | no |
| UnsupportedDictionaryWrapper_Wrapper | {"Wrapper":null} | no | no |
| UnsupportedDictionaryWrapper_Wrapper | {"Wrapper":{"Dictionary":{}} | yes | yes |
| UnsupportedDictionaryWrapper_Wrapper | {"Wrapper":{"Dictionary":null} | yes | no |
| List<Dictionary<int[], int>> | no | no | |
| List<Dictionary<int[], int>> | null | no | no |
| List<Dictionary<int[], int>> | [] | yes | no |
| List<Dictionary<int[], int>> | [""] | yes | no |
| List<Dictionary<int[], int>> | [null] | yes | no |
| List<Dictionary<int[], int>> | [{}] | yes | yes |
cc @layomia
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Delayed validation makes sense to me. We do that in other cases as well for perf (including validating collection elements, elements of elements, etc).
As long as we continue to have tests for unsupported cases we should be fine. We should also check the Exception messages for the proper substrings (might require #32669)
| // Support JSON Path on exceptions. | ||
| public byte[]? JsonPropertyName; // This is Utf8 since we don't want to convert to string until an exception is thown. | ||
| public string? JsonPropertyNameAsString; // This is used for dictionary keys and re-entry cases that specify a property name. | ||
| internal byte[]? DictionaryKeyName; // This will contain the Utf8 Json property name that represents a dictionary key; used to defer parsing on async/re-entry. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I realized that I can re-use JsonPropertyName for this purpose, however, JsonPropertyName will no longer be exclusive for exceptions.
@steveharter @layomia
...ries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Converters.cs
Outdated
Show resolved
Hide resolved
....Text.Json/src/System/Text/Json/Serialization/Converters/KeyConverters/StringKeyConverter.cs
Outdated
Show resolved
Hide resolved
| ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(TypeToConvert); | ||
| } | ||
|
|
||
| state.Current.JsonPropertyNameAsString = reader.GetString(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still need to set JsonPropertyNameAsString?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since DefaultDictionaryConverter.OnTryRead is still being used by other dictionaries like ImmutableDictionary or IDictionary; right now it is still needed.
We can remove it once we spread the TKey changes to those converters as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To keep the code clean/consistent (and to avoid the unnecessary GetString() call here), we should extend key support for all the dictionary converters.
|
|
||
| namespace System.Text.Json.Serialization.Converters | ||
| { | ||
| internal sealed class ObjectKeyConverter : KeyConverter<object> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we just use JsonDocument, how valuable is this?
What does GetHashCode() return from JsonDocument?
I think this scenario would be valuable once we add true polymorphic deserialization (and not just use JsonDocument).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't quite understand the concern here. What this converter does is take the JSON property name as a string and create a JsonElement when the TKey is object in order to emulate the behavior of the ObjectConverter
What does the GetHashCode() method have to do here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The string-based key creates a JsonElement which is used as a key in a dictionary, so GetHashCode() is called on the JsonElement by the dictionary during get\add operations.
Since JsonElement uses a default GetHashCode() implementation I don't see any value of having it as a key. If instead we supported polymorphic deserialization, the resulting object (not JsonElement) could have its own implementation of GetHashCode() and thus be useful as a key.
Also since JsonElement is a value type, the Equals() implementation won't be ideal since it will call Equals() on each field (slow). In summary, JsonElement was not designed to support being a dictionary key.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should not support such type, otherwise we would fail on round-tripping if someone have a property Dictionary<object, object>.
What does GetHashCode() return from JsonDocument?
I think you mean JsonElement. It does return different values even for the same string so yes, you might have problems on get/add operations on the dictionary https://dotnetfiddle.net/pVlMi3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should wait until we have an option to return boxed primitives over JsonElement before we consider allowing typeof(object) as a dictionary key.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should wait until we have an option to return boxed primitives over
JsonElementbefore we consider allowingtypeof(object)as a dictionary key.
Why? We have object property support already, why not extend that for dictionary keys now with the same semantics? We still need to flesh out the semantics/behavior when folks don't use that option, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We still need to flesh out the semantics/behavior when folks don't use that option, right?
That's fair.
For reasons called out in https://github.com/dotnet/runtime/pull/32909/files/53bfb9636b04b157b945ca59a1a6e958a791e00b#r385742410, calling .ContainsKey on the dictionary would only work if you "know" the specific JsonElement you are querying. The dictionary would only be useful when iterating over all key-value pairs. Lookup would be busted in general.
| { | ||
| get | ||
| { | ||
| if (_keyConverter == null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if a different approach would be to add new internal methods to JsonConverter to support keys. That way, we can just use the existing converters and don't need a new variable here, or add a new static ConcurrentDictionary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, you mean virtual methods that are implemented only for Dictionary converters, right?
But then how do you determine which method call for each specific TKey depending on its type?
You would have to use delegates (or something alike) for each type, i.e. one for int, one for Guid, etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking we could add two new virtual methods to JsonConverter<T> like:
internal virtual string ReadAsString(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ throw new NotSupportedException(); }
internal virtual void WriteAsString(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options)
{ throw new NotSupportedException(); }which would be implemented by the Int32Converter, GuidConverter, etc.
* Throw InvalidOperationException on StringKeyConverter
|
CI issue is unrelated and is being tracked on #2280. |
| ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(TypeToConvert); | ||
| } | ||
|
|
||
| state.Current.JsonPropertyNameAsString = reader.GetString(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To keep the code clean/consistent (and to avoid the unnecessary GetString() call here), we should extend key support for all the dictionary converters.
| where TCollection : IDictionary | ||
| { | ||
| protected override void Add(object? value, JsonSerializerOptions options, ref ReadStack state) | ||
| protected override void Add(string _, object? value, JsonSerializerOptions options, ref ReadStack state) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would should use the key parsed with the key converter in DictionaryDefaultConverter instead of JsonPropertyNameAsString.
| // If we need to apply the policy, we are forced to get a string since that is the only type that ConvertName can take as argument. | ||
| else if (options.DictionaryKeyPolicy != null && !state.Current.IgnoreDictionaryKeyPolicy) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why would one want a naming policy applied to anything that is not a string e.g. Guid, Uri, Int32? I think we should only apply this on strings and wait for user feedback before extending it. We'll also avoid extra ConvertName calls.
|
|
||
| namespace System.Text.Json.Serialization.Converters | ||
| { | ||
| internal sealed class ObjectKeyConverter : KeyConverter<object> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should wait until we have an option to return boxed primitives over JsonElement before we consider allowing typeof(object) as a dictionary key.
Fixes #30524
Introduces the mechanism that is going to be used for extending the supported types on
TKeyin aDictionary<TKey, TValue>. The purpose of this PR is to give a taste of the way that we are going to tackle the problem.Adds support for
int,enum,Guidandobject(Whenobjectis one of the supported types during runtime).In subsequent PRs we should be able to extend this support to the rest of dictionaries supported by the
JsonSerializeri.e. IDIctionary<TKey, TValue>, ImmutableDictionary<TKey, TValue>, etc.I hold off of adding support for dictionaries annotated with the
JsonExtensionDataattribute but those can also be included in the previous paragraph.