Skip to content
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

Port UnionConverter to STJ #43

Closed
wants to merge 19 commits into from
Closed

Port UnionConverter to STJ #43

wants to merge 19 commits into from

Conversation

NickDarvey
Copy link

@NickDarvey NickDarvey commented Mar 31, 2020

WIP reimplementing FsCodec.NewtonsoftJson.UnionConverter on System.Text.Json re #14 feeding into #38

To do

  • Ensure basic de/serialization passes, round-tripping
  • Ensure upconverting of missing properties to Nones or Nullables
    (this is covered by existing tests, like CaseJ and CaseT)
  • Resolve all open review comments

@CLAassistant
Copy link

CLAassistant commented Mar 31, 2020

CLA assistant check
All committers have signed the CLA.

Comment on lines +294 to +266
//[<Fact>]
//let ``Implementation ensures no internal errors escape (which would render a WebApi ModelState.Invalid)`` () =
// let s = JsonSerializer.CreateDefault()
// let mutable gotError = false
// s.Error.Add(fun _ -> gotError <- true)

// let dJson = """{"case":"CaseD","a":"hi"}"""
// use dReader = new StringReader(dJson)
// use dJsonReader = new JsonTextReader(dReader)
// let d = s.Deserialize<TestDU>(dJsonReader)

// test <@ (CaseD "hi") = d @>
// test <@ false = gotError @>
Copy link
Author

@NickDarvey NickDarvey Apr 13, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: I'll need to poke around to see how they have integrated STJ with ASP.NET's modelstate to make sure I can test for this behaviour. (JsonSerializer.Error is not a thing.)

@bartelink bartelink mentioned this pull request Apr 14, 2020
9 tasks
src/FsCodec.SystemTextJson/Options.fs Outdated Show resolved Hide resolved
src/FsCodec.SystemTextJson/UnionConverter.fs Outdated Show resolved Hide resolved
src/FsCodec.SystemTextJson/UnionConverter.fs Outdated Show resolved Hide resolved
tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs Outdated Show resolved Hide resolved
test <@ CaseK (1, Nullable 2) = deserialize """{"case":"CaseK", "a":1, "b":2 }""" @>
test <@ CaseL (Nullable 1, Nullable 2) = deserialize """{"case":"CaseL", "a": 1, "b": 2 }""" @>

// TOINVESTIGATE: It looks like Newtonsoft Settings.Create() behaviour is to always add
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, Options.Create adds a JsonOptionsConverter and Settings.Create similarly - you would end up with a Value nesting if its not in the mix - at one point defaultOptions might have referred to settings created by Settings.CreateDefault(), which adds no converters of any kind
It'd be nice for this to be as tight as possible in terms of numbers of (esp global) converters it throws into the mix

tests/FsCodec.SystemTextJson.Tests/UnionConverterTests.fs Outdated Show resolved Hide resolved
| Catchall

[<Fact>]
let ``UnionConverter supports a nominated catchall via options`` () =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure it did this before; not sure its a good idea as its a recipe for catchall case not found exceptions

(In general, global converters are a bad idea as they're magic IMO)

Not sure if you agree, but in my book this test should demonstrate random unions don't get affected (people like building SCDU converters etc, but again, I'd prefer not to conflict with such semantics as UnionConverter has plenty semantics just dealing with what it already covers)

type DuWithMissingCatchAll =
| Known

[<Fact>]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above comment

tests/FsCodec.SystemTextJson.Tests/UnionConverterTests.fs Outdated Show resolved Hide resolved
@bartelink
Copy link
Collaborator

Some generic commentary regarding reasoning behind my comments about the behavior of the converters in FsCodec (but also more broadly) when registered globally:

Especially for UnionConverter my high level opinions are:
a) one should not be using unions or fancy things in JSON for simplicity of semantics across lots of consumer languages
b) when used, you want to know about it

having that hardline stance also brings some benefits:
a) less test cases - just prove it doesnt muck about with stuff not tagged with a converter
b) (esp in Equinox, where its chief use is for roundtripping State unions in a state-machine stylee being used as snapshots) forcing the tagging makes people think a bit in the course of moving the state type to be in the module Events in the module Aggregate

I did a commit or two recently in stj to similarly reduce greediness in TypeSafeEnum.isTypeSafeEnum -

I've not expressed this philosophy well in the README as yet, but it also seems to align with how stuff is in STJ in general (vs newtonsoftland where greedier convention based converters are less frowned on)

finally in addition to Explicit being better than Implicit in general, it also tends to work well for perf - i.e. the converter gets detected at type level and then lives in the mappings cache, vs if it is global it is a tax on every type walk that happens
not sure if there is an article on this; there should be!

aside: there are alternate views out there - for instance @ylibrach and his team have a wide array of converters, including registering quite a few globally

but bottom line is that in the FsCodec context:

  • for TypeSafeEnum its good you're forced to tag them so people understand there needs to be a canonical mapping (and maybe an anticorruption layer if you dont like that)
  • for RecordConverter and OptionConverter obv it would be a pain for them not to be global
  • for JsonIsomorphism/JsonPickler, you want people to be aware right on the type
  • there are no other converters you'd commonly use, esp for event codecs, but IME also for the bulk of request/response bodies (and I guess what gets supported in STJ in .NET 5 and later will also drive what people expect to do wrt binding)

... so UnionConverter being required to be marked explicitly on the concrete union type simply follows from that

@NickDarvey
Copy link
Author

the converter gets detected at type level and then lives in the mappings cache, vs if it is global it is a tax on every type walk that happens

I'm not sure if this is what you mean, but they do cache converters created by the factory. Though I guess specifying it as a concrete type saves that first-run reflection still.

... so UnionConverter being required to be marked explicitly on the concrete union type simply follows from that

Understood, less greedy converters makes more sense for STJ and not having them so easily available pushes our users toward being more explicit about their serialization.

I'm going to remove the JsonConverterFactory so our users are pushed toward being more explicit when they're serializing DUs.

@bartelink
Copy link
Collaborator

I'm not sure if this is what you mean, but they do cache converters created by the factory. Though I guess specifying it as a concrete type saves that first-run reflection still.

I was referring to the fact that the set of globally registered converters will be consulted per property as it determines the mapping path that'll apply (even if that's cached per type after that).

/// <remarks>Not inherited because JsonConverters don't get inherited right now.
/// https://github.com/dotnet/runtime/issues/30427#issuecomment-610080138</remarks>
[<AttributeUsage(AttributeTargets.Class ||| AttributeTargets.Struct, AllowMultiple = false, Inherited = false)>]
type JsonUnionConverterOptionsAttribute(discriminator : string) =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason to make this mandatory ? the catchall is probably more likely to be customized?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wall of text relates to a couple of items of feedback, and I realized caused me a bit of churn in my commits, but this is the piece of feedback that made me realized I needed to articulate my thoughts so I've placed it here.


I think I was requiring the discriminator to be specified as a way to solve an issue I was having about inferring the user's intentions with these annotations and the outcome they'd expect.

Context

Let's imagine the discriminator is not required right now, both Discriminator and CatchAllCase are both nullable on JsonUnionConverterOptionsAttribute, a user can set one, both, or none. We also have default options set on the converter, "case" as a discriminator and no catch-all case.

I'll use two imaginary DUs here called DiscriminatorOption and CatchAllCaseOption to represent the possible user intents when they are setting options via an attribute:

type DiscriminatorOption =
| CustomDiscriminator of string
| DefaultDiscriminator

type CatchAllCaseOption =
| CustomCase of string
| NoCase
| Default

Expected behaviour

Inferring a user's intended DiscriminatorOption when they set the Discriminator of the attribute is fine as it's binary and quite understandable when represented with null/not-null:

[JsonUnionConverterOptions(Discriminator = "type")]
= DiscriminatorOption.CustomDiscriminator "type"
= "I want to set a specific discriminator for this union"
[JsonUnionConverterOptions()]
= DiscriminatorOption.DefaultDiscriminator
= "I want to use whatever the default discriminator is."

However CatchAllCaseOption is ternary and I don't know what users would expect:

[JsonUnionConverterOptions(CatchAllCase = "other")]
= CatchAllCaseOption.CustomCase "other"
= "I want to set a specific catch-all case for this union"
[JsonUnionConverterOptions()]
= ...? Does this mean they want no catch-all case or that they want to use the default catch-all case option

Attempted solution

My attempted solution for that was making the discriminator mandatory so it was explicit users were choosing custom options.
If they set the attribute they are saying DiscriminatorOption = CustomDiscriminator and CatchAllCaseOption = CustomCase | NoCase; conversely 'DefaultDiscriminator' and 'DefaultCase' were selectable by not having the attribute at all.

However you raise a good point that the use case for this attribute is mainly setting the CatchAllCaseOption (and users will probably be happy with the default discriminator).

Potential solutions

Here's are a couple of options I thought of to try to address understanding the user's intent but also that use case:

  1. Split these options into two attributes: JsonUnionConverterDiscriminatorAttribute; JsonUnionConverterCatchAllCaseAttribute

    [JsonUnionConverterDiscriminator("type")]
    = DiscriminatorOption.CustomDiscriminator "type"
    = "I want to set a specific discriminator for this union"
    
    (*no JsonUnionConverterDiscriminator attribute*)
    = DiscriminatorOption.DefaultDiscriminator
    = "I want to use whatever the default discriminator is."`
    
    [JsonUnionConverterCatchAllCase(CaseName = "other")]
    = CatchAllCaseOption.CustomCase "other"
    = "I want to set a specific catch-all case for this union"
    
    [JsonUnionConverterCatchAllCase()]
    = CatchAllCaseOption.NoCase
    = "I want no catch-all case for this union"
    
    (*no JsonUnionConverterCatchAllCase attribute*)
    = CatchAllCaseOption.Default
    = "I want the default catch-all case for this union, if there is one"
    
  2. Stick with JsonUnionConverterOptionsAttribute but make the discriminator not required, and infer a null CatchAllCase to mean CatchAllCaseOption.NoCase, overriding whatever is the default option (which as it happens, is also no catch-all case).
    If this is done, I think I also ought to remove the catchAllCase constructor parameter from UnionConverter so users can't set a catchAllCase if they are manually creating and registering a converter via Options.Create. This is because if they then set wanted to set a custom discriminator via an attribute, this solution would now infer it as them also choosing CatchAllCaseOption.NoCase (which would override what they set in the constructor of the converter which I think would be unexpected behaviour).

Next steps

If you have a strong feeling about those options, spot flaws in my reasoning, or better suggestions please let me know, otherwise I'll go ahead an implement the solution described in 1 in the next couple of days.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A catch-all is not the common case. As with TypeSafeEnumConverter, simply rejecting by throwing is fine.

There never was the notion of a 'default catch-all' (it was added as a feature later, and e.g. for that to become a default would be annoying/breaking).

I thus prefer a single options attribute, and removing the notion of CatchAllCaseOption.Default

Having said all that, it's up to you - my default stance as an implementer is to work toward an easy to scan impl and easy to test (meaning easy to define) spec (which I think matches the preceding - KISS principle ;)).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔... unless the default catch-all was defined as the single case with a body that's a JsonElement, but thats a pretty loose approach not in alignment with STJ design in general, I'd say ;)

I think with these things, in practice, explicit is better than implicit - the default mode of operation is that a) unions dont get converted b) applying the JsonUnionConverter means they get converted a single way (using "case") with no default case
c) only by applying the JsonUnionConverterOptions would you every end up with catch-all semantics

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, excellent. That works for me.

I will stick with a single options attribute and remove the notion of CatchAllCaseOption.Default by removing catchAllCase from the UnionConverter constructor.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RE: Catch-all in ctor arg

I think this has the issue around understanding the user's intent

What if they have registered a converter and specified a catch-all case via the ctor,

Options.Create(UnionConverter<MyDU>((*discriminator*) "type", (*catchAllCase*) "other")

and then use the attribute to specify a custom discriminator (but left out the catch-all case in said attribute)?

[JsonUnionConverterOptions(Discriminator = "kind")]

Would you (the user) expect the converter to use the catch-all case as specified in the ctor, or for it to not use a catch-all case?

And then what would the user be trying to do if they did this?

[JsonUnionConverterOptions(CatchAllCase = null)]

(I know we, the converter, can't differentiate that from the previous example, but in terms of user intent...)

Copy link
Collaborator

@bartelink bartelink Apr 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this attribute is set, its values take precedence over the values set on the converter via its constructor.

not getting why this needs to be ?

I thought I/the user would only expect the JsonUnionConverterOptionsAttribute to come into play if I'd asked for the conversion via JsonConverterAttribute; if I programmatically configure it by calling a ctor with 2 defaulting args (with xmldoc saying this is the choice, attributes are not looked up)

i.e. the default ctor could do the lookup and then pass the opts to the primary constructor:

type JsonUnionConverter<'T>(discriminator, ?catchAll) =
    ...
    do  match catchAll with
        | None -> ()
        | Some ca ->
              <throw if invalid case nominated>
    new() =
        let d, ca = <attribute lookup on T>, default to "case" for d
        JsonUnionConverter(discriminator=d, ?catchAll=ca) 

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhh okay, that was our misalignment. I had assumed you/the user might use JsonUnionConverterOptionsAttribute without JsonConverterAttribute.
('might', I merely mean we can't stop someone applying the options attribute even though they're registering the converter themselves, even though it might not be a great idea.)

I feel like I understand your position now. I'm happy to implement so we only fetch the attributes there are no ctor arguments supplied. (And yes, validating the nominated case is a good idea.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, sorry for any confusion caused to steer you into this direction (I'm extremely lazy and would never even have considered going to the lengths of completeness you were considering; a lack of imagination on my part!)

I think ultimately this should yield a compact impl that one can scan and grok without referring to this whole dialogue in the end!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compact impl sounds good

/// Paralells F# behavior wrt how it generates a DU's underlying .NET Type
let inline isInlinedIntoUnionItem (t : Type) =
t = typeof<string>
//|| t.IsValueType
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 if you have a union of int * int, is this not needed? (no need to respond/explain, have not looked at full code)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think you're right. I think what this actually needs is a check for JsonElement for when we're keeping a JsonElement field, see #43 (comment)

| null when options.IgnoreNullValues -> ()
| fv ->
let element = JsonSerializer.SerializeToElement(fv, options)
match element.ValueKind with
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if?


new() = UnionConverter("case", null)
new(discriminator: string, catchAllCase: string) = // Compatibility with Newtonsoft UnionConverter constructor
UnionConverter(discriminator, match catchAllCase with null -> None | x -> Some x)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option.ofObj catchAllCase ?

static let converterType = typedefof<UnionConverter<_>>

new() = UnionConverter("case", null)
new(discriminator: string, catchAllCase: string) = // Compatibility with Newtonsoft UnionConverter constructor
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this collapse into a primary ctor with 2 optional args which defaults to "case" for discriminator ?

override _.CreateConverter(t, _) =
let options = t.GetCustomAttributes(typeof<JsonUnionConverterOptionsAttribute>, false)
|> Array.tryHead // AttributeUsage(AllowMultiple = false)
|> Option.map (fun a -> a :?> JsonUnionConverterOptionsAttribute)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think :?> can become :>

let options = t.GetCustomAttributes(typeof<JsonUnionConverterOptionsAttribute>, false)
|> Array.tryHead // AttributeUsage(AllowMultiple = false)
|> Option.map (fun a -> a :?> JsonUnionConverterOptionsAttribute)
let discriminator = options |> Option.map (fun o -> o.Discriminator)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can/should that defaulting go into the converter itself?

roundtripProperty ignoreNulls profile x

//[<Fact>]
//let ``Implementation ensures no internal errors escape (which would render a WebApi ModelState.Invalid)`` () =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, I think this can simply be removed - there's no anologous facility in STJ, and we'll know from exceptions if there are problems

type DuWithoutAttributes =
| Case1

[<JsonUnionConverterOptions("kind")>]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name suggests the converter should prob be renamed to JsonUnionConverter?


open Samples

// TODO support [<Struct>]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix comments below?

bartelink and others added 4 commits April 26, 2020 00:39
* Replaces Newtonsoft bits from scaffold with the actual STJ bits
* Tries to retain symmetry with Newtonsoft UnionConverter impl
(Oh, they're all failing)
* Wrong namespace lol, and
* It seems like boxing a DU means that STJ won't use the convert for serialization. Maybe this is something to do with derived types and JsonConverter dotnet/runtime#30427
https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to
Newtonsoft.Json can be configured to throw exceptions during deserialization if the JSON includes properties that are missing in the target type. System.Text.Json ignores extra properties in the JSON, except when you use the [JsonExtensionData] attribute. There's no workaround for the missing member feature.
Uses the provided type parameter so that we strictly only convert the union that we were asked to convert
@@ -153,7 +153,7 @@ let ``deserializes properly`` () =

let (|Q|) (s: string) = JsonSerializer.Serialize(s, defaultOptions)

// Renderings when NullValueHandling=Include, which is the default for Json.net, and used by the recommended Settings.CreateCorrect profile
// Renderings when NullValueHandling=Include, which is used by the recommended Settings.Create profile
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Options.Create

@bartelink
Copy link
Collaborator

Semi related: stumbled on @ylibrach's dotnet/runtime#1130 just now too

@bartelink
Copy link
Collaborator

I've had out of band discussions with Nick regarding this PR. The status is as follows:

  • the logic is pretty much complete
  • Nick has parked the work in order to be able to focus wholly on another time-sensitive side-project
  • Nick is open to someone else bringing the PR to completion in the (likely) event he doesn't get back to it before someone needs it

@bartelink
Copy link
Collaborator

Argh! Didnt mean for this PR to be closed - I promise I'll get back on rebasing and merging it soon!

Can you make sure to keep this around for when that time comes please?

@bartelink bartelink mentioned this pull request Dec 9, 2020
4 tasks
bartelink added a commit that referenced this pull request Jan 4, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants