Skip to content

Commit

Permalink
Newtonsoft: Sync TypeSafeEnum signatures (#91)
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink authored Apr 26, 2023
1 parent 9385324 commit 37a5731
Show file tree
Hide file tree
Showing 8 changed files with 75 additions and 66 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ The `Unreleased` section name is replaced by the expected version of next releas

### Added
### Changed

- `NewtonsoftJson.TypeSafeEnum`: Sync with `SystemTextJson.TypeSafeEnum` [#91](https://github.com/jet/FsCodec/pull/91)

### Removed
### Fixed

Expand Down
8 changes: 4 additions & 4 deletions src/FsCodec.NewtonsoftJson/OptionConverter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type OptionConverter() =
if value = null then null
else
let _, fields = FSharpValue.GetUnionFields(value, value.GetType())
fields.[0]
fields[0]

serializer.Serialize(writer, value)

Expand All @@ -26,8 +26,8 @@ type OptionConverter() =
else innerType

let cases = Union.getUnionCases t
if reader.TokenType = JsonToken.Null then FSharpValue.MakeUnion(cases.[0], Array.empty)
if reader.TokenType = JsonToken.Null then FSharpValue.MakeUnion(cases[0], Array.empty)
else
let value = serializer.Deserialize(reader, innerType)
if value = null then FSharpValue.MakeUnion(cases.[0], Array.empty)
else FSharpValue.MakeUnion(cases.[1], [|value|])
if value = null then FSharpValue.MakeUnion(cases[0], Array.empty)
else FSharpValue.MakeUnion(cases[1], [|value|])
20 changes: 10 additions & 10 deletions src/FsCodec.NewtonsoftJson/Options.fs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ type Options private () =
/// Creates a default set of serializer settings used by Json serialization. When used with no args, same as JsonSerializerSettings.CreateDefault()
static member CreateDefault
( [<Optional; ParamArray>] converters : JsonConverter[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
/// Render idiomatic camelCase for PascalCase items by using `CamelCasePropertyNamesContractResolver`. Defaults to false.
// Render idiomatic camelCase for PascalCase items by using `CamelCasePropertyNamesContractResolver`. Defaults to false.
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
/// Ignore null values in input data; defaults to false.
// Ignore null values in input data; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool,
/// Error on missing values (as opposed to letting them just be default-initialized); defaults to false.
// Error on missing values (as opposed to letting them just be default-initialized); defaults to false.
[<Optional; DefaultParameterValue(null)>] ?errorOnMissing : bool) =

let indent = defaultArg indent false
Expand All @@ -48,16 +48,16 @@ type Options private () =
/// - Always prepends an OptionConverter() to any converters supplied
/// - everything else is as per CreateDefault:- i.e. emit nulls instead of omitting fields etc
static member Create
( /// List of converters to apply. An implicit OptionConverter() will be prepended and/or be used as a default
( // List of converters to apply. An implicit OptionConverter() will be prepended and/or be used as a default
[<Optional; ParamArray>] converters : JsonConverter[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
/// Render idiomatic camelCase for PascalCase items by using `CamelCasePropertyNamesContractResolver`.
/// Defaults to false on basis that you'll use record and tuple field names that are camelCase (and hence not `CLSCompliant`).
// Render idiomatic camelCase for PascalCase items by using `CamelCasePropertyNamesContractResolver`.
// Defaults to false on basis that you'll use record and tuple field names that are camelCase (and hence not `CLSCompliant`).
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
/// Ignore null values in input data; defaults to `false`.
// Ignore null values in input data; defaults to `false`.
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool,
/// Error on missing values (as opposed to letting them just be default-initialized); defaults to false
// Error on missing values (as opposed to letting them just be default-initialized); defaults to false
[<Optional; DefaultParameterValue(null)>] ?errorOnMissing : bool) =

Options.CreateDefault(
Expand Down
27 changes: 15 additions & 12 deletions src/FsCodec.NewtonsoftJson/TypeSafeEnumConverter.fs
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
namespace FsCodec.NewtonsoftJson

open Newtonsoft.Json
open System.Collections.Generic
open System
open System.Collections.Generic

/// Utilities for working with DUs where none of the cases have a value
module TypeSafeEnum =

let isTypeSafeEnum (t : Type) =
Union.isUnion t
&& (Union.getUnion t).cases |> Seq.forall (fun case -> case.GetFields().Length = 0)
&& Union.hasOnlyNullaryCases t

let tryParseT (t : Type) (str : string) =
let union = Union.getUnion t
union.cases
|> Array.tryFindIndex (fun case -> case.Name = str)
|> Option.map (fun tag -> (union.caseConstructor.[tag] [||]))
let u = Union.getInfo t
u.cases
|> Array.tryFindIndex (fun c -> c.Name = str)
|> Option.map (fun tag -> u.caseConstructor[tag] [||])
let tryParse<'T> (str : string) = tryParseT typeof<'T> str |> Option.map (fun e -> e :?> 'T)

let parseT (t : Type) (str : string) =
Expand All @@ -26,19 +26,22 @@ module TypeSafeEnum =
raise (KeyNotFoundException(sprintf "Could not find case '%s' for type '%s'" str t.FullName))
let parse<'T> (str : string) = parseT typeof<'T> str :?> 'T

let toString (x : obj) =
let union = Union.getUnion (x.GetType())
let tag = union.tagReader x
union.cases.[tag].Name
let toStringT (t : Type) (x : obj) =
let u = Union.getInfo t
let tag = u.tagReader x
u.cases[tag].Name
let toString<'t> (x : 't) =
toStringT typeof<'t> x

/// Maps strings to/from Union cases; refuses to convert for values not in the Union
type TypeSafeEnumConverter() =
inherit JsonConverter()

override _.CanConvert (t : Type) = TypeSafeEnum.isTypeSafeEnum t
override _.CanConvert(t : Type) =
TypeSafeEnum.isTypeSafeEnum t

override _.WriteJson(writer : JsonWriter, value : obj, _ : JsonSerializer) =
let str = TypeSafeEnum.toString value
let str = TypeSafeEnum.toStringT (value.GetType()) value
writer.WriteValue str

override _.ReadJson(reader : JsonReader, t : Type, _ : obj, _ : JsonSerializer) =
Expand Down
25 changes: 15 additions & 10 deletions src/FsCodec.NewtonsoftJson/UnionConverter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ module private Union =
let isUnion = memoize (fun t -> FSharpType.IsUnion(t, true))
let getUnionCases = memoize (fun t -> FSharpType.GetUnionCases(t, true))

let private createUnion t =
let private createInfo t =
let cases = getUnionCases t
{
cases = cases
tagReader = FSharpValue.PreComputeUnionTagReader(t, true)
fieldReader = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionReader(c, true))
caseConstructor = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionConstructor(c, true))
}
let getUnion = memoize createUnion
let getInfo = memoize createInfo

/// Allows us to distinguish Unions that do not have bodies and hence should use a TypeSafeEnumConverter
let hasOnlyNullaryCases (t : Type) =
let union = getInfo t
union.cases |> Seq.forall (fun case -> case.GetFields().Length = 0)

/// Parallels F# behavior wrt how it generates a DU's underlying .NET Type
let inline isInlinedIntoUnionItem (t : Type) =
Expand All @@ -53,7 +58,7 @@ module private Union =
[| inputJObject.ToObject(singleCaseArg.PropertyType, serializer) |]
| multipleFieldsInCustomCaseType ->
[| for fi in multipleFieldsInCustomCaseType ->
match inputJObject.[fi.Name] with
match inputJObject[fi.Name] with
| null when
// Afford converters an opportunity to handle the missing field in the best way I can figure out to signal that
// The specific need being covered (see tests) is to ensure that, even with MissingMemberHandling=Ignore,
Expand All @@ -79,10 +84,10 @@ type UnionConverter private (discriminator : string, ?catchAllCase) =
override _.CanConvert (t : Type) = Union.isUnion t

override _.WriteJson(writer : JsonWriter, value : obj, serializer : JsonSerializer) =
let union = Union.getUnion (value.GetType())
let union = Union.getInfo (value.GetType())
let tag = union.tagReader value
let case = union.cases.[tag]
let fieldValues = union.fieldReader.[tag] value
let case = union.cases[tag]
let fieldValues = union.fieldReader[tag] value
let fieldInfos = case.GetFields()

writer.WriteStartObject()
Expand All @@ -92,7 +97,7 @@ type UnionConverter private (discriminator : string, ?catchAllCase) =

match fieldInfos with
| [| fi |] when not (Union.typeIsUnionWithConverterAttribute fi.PropertyType) ->
match fieldValues.[0] with
match fieldValues[0] with
| null when serializer.NullValueHandling = NullValueHandling.Ignore -> ()
| fv ->
let token = if fv = null then JToken.Parse "null" else JToken.FromObject(fv, serializer)
Expand All @@ -117,9 +122,9 @@ type UnionConverter private (discriminator : string, ?catchAllCase) =
if token.Type <> JTokenType.Object then raise (FormatException(sprintf "Expected object token, got %O" token.Type))
let inputJObject = token :?> JObject

let union = Union.getUnion t
let union = Union.getInfo t
let targetCaseIndex =
let inputCaseNameValue = inputJObject.[discriminator] |> string
let inputCaseNameValue = inputJObject[discriminator] |> string
let findCaseNamed x = union.cases |> Array.tryFindIndex (fun case -> case.Name = x)
match findCaseNamed inputCaseNameValue, catchAllCase with
| None, None ->
Expand All @@ -133,5 +138,5 @@ type UnionConverter private (discriminator : string, ?catchAllCase) =
inputCaseNameValue catchAllCaseName t.FullName |> invalidOp
| Some foundIndex -> foundIndex

let targetCaseFields, targetCaseCtor = union.cases.[targetCaseIndex].GetFields(), union.caseConstructor.[targetCaseIndex]
let targetCaseFields, targetCaseCtor = union.cases[targetCaseIndex].GetFields(), union.caseConstructor[targetCaseIndex]
targetCaseCtor (Union.mapTargetCaseArgs inputJObject serializer targetCaseFields)
26 changes: 13 additions & 13 deletions src/FsCodec.SystemTextJson/Options.fs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ type Options private () =
/// Creates a default set of serializer options used by Json serialization. When used with no args, same as `JsonSerializerOptions()`
static member CreateDefault
( [<Optional; ParamArray>] converters : JsonConverter[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
/// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. Defaults to false.
// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. Defaults to false.
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
/// Ignore null values in input data, don't render fields with null values; defaults to `false`.
// Ignore null values in input data, don't render fields with null values; defaults to `false`.
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool,
/// Drop escaping of HTML-sensitive characters. defaults to `false`.
// Drop escaping of HTML-sensitive characters. defaults to `false`.
[<Optional; DefaultParameterValue(null)>] ?unsafeRelaxedJsonEscaping : bool) =

let indent = defaultArg indent false
Expand All @@ -43,22 +43,22 @@ type Options private () =
/// - renders values with `UnsafeRelaxedJsonEscaping` - i.e. minimal escaping as per `NewtonsoftJson`<br/>
/// Everything else is as per CreateDefault:- i.e. emit nulls instead of omitting fields, no indenting, no camelCase conversion
static member Create
( /// List of converters to apply. Implicit converters may be prepended and/or be used as a default
( // List of converters to apply. Implicit converters may be prepended and/or be used as a default
[<Optional; ParamArray>] converters : JsonConverter[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
/// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`.
/// Defaults to false on basis that you'll use record and tuple field names that are camelCase (but thus not `CLSCompliant`).
// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`.
// Defaults to false on basis that you'll use record and tuple field names that are camelCase (but thus not `CLSCompliant`).
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
/// Ignore null values in input data, don't render fields with null values; defaults to `false`.
// Ignore null values in input data, don't render fields with null values; defaults to `false`.
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool,
/// Drop escaping of HTML-sensitive characters. defaults to `true`.
// Drop escaping of HTML-sensitive characters. defaults to `true`.
[<Optional; DefaultParameterValue(null)>] ?unsafeRelaxedJsonEscaping : bool,
/// <summary>Apply <c>TypeSafeEnumConverter</c> if possible. Defaults to <c>false</c>.</summary>
// <summary>Apply <c>TypeSafeEnumConverter</c> if possible. Defaults to <c>false</c>.</summary>
[<Optional; DefaultParameterValue(null)>] ?autoTypeSafeEnumToJsonString : bool,
/// <summary>Apply <c>UnionConverter</c> for all Discriminated Unions, if <c>TypeSafeEnumConverter</c> not possible. Defaults to <c>false</c>.</summary>
// <summary>Apply <c>UnionConverter</c> for all Discriminated Unions, if <c>TypeSafeEnumConverter</c> not possible. Defaults to <c>false</c>.</summary>
[<Optional; DefaultParameterValue(null)>] ?autoUnionToJsonObject : bool,
/// <summary>Apply <c>RejectNullStringConverter</c> in order to have serialization throw on <c>null</c> strings. Use <c>string option</c> to represent strings that can potentially be <c>null</c>.
// <summary>Apply <c>RejectNullStringConverter</c> in order to have serialization throw on <c>null</c> strings. Use <c>string option</c> to represent strings that can potentially be <c>null</c>.
[<Optional; DefaultParameterValue(null)>] ?rejectNullStrings: bool) =

let autoTypeSafeEnumToJsonString = defaultArg autoTypeSafeEnumToJsonString false
Expand Down
24 changes: 11 additions & 13 deletions src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,19 @@ open System.Text.Json
/// Utilities for working with DUs where none of the cases have a value
module TypeSafeEnum =

let isTypeSafeEnum (typ : Type) =
Union.isUnion typ
&& Union.hasOnlyNullaryCases typ
let isTypeSafeEnum (t : Type) =
Union.isUnion t
&& Union.hasOnlyNullaryCases t

let tryParseT (t : Type) predicate =
let tryParseT (t : Type) (str : string) =
let u = Union.getInfo t
u.cases
|> Array.tryFindIndex (fun c -> predicate c.Name)
|> Option.map (fun tag -> u.caseConstructor.[tag] [||])
// TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137
let tryParse<'T> (str : string) = tryParseT typeof<'T> ((=) str) |> Option.map (fun e -> e :?> 'T)
|> Array.tryFindIndex (fun c -> c.Name = str)
|> Option.map (fun tag -> u.caseConstructor[tag] [||])
let tryParse<'T> (str : string) = tryParseT typeof<'T> str |> Option.map (fun e -> e :?> 'T)

let parseT (t : Type) (str : string) =
match tryParseT t ((=) str) with
let parseT (t : Type) (str : string) =
match tryParseT t str with
| Some e -> e
| None ->
// Keep exception compat, but augment with a meaningful message.
Expand All @@ -29,9 +28,8 @@ module TypeSafeEnum =

let toString<'t> (x : 't) =
let u = Union.getInfo typeof<'t>
let tag = u.tagReader (box x)
// TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137
u.cases.[tag].Name
let tag = u.tagReader x
u.cases[tag].Name

/// Maps strings to/from Union cases; refuses to convert for values not in the Union
type TypeSafeEnumConverter<'T>() =
Expand Down
8 changes: 4 additions & 4 deletions src/FsCodec.SystemTextJson/UnionConverter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ module private Union =
|> Option.map (fun a -> a :?> IUnionConverterOptions) }
let getInfo : Type -> Union = memoize createInfo

/// Allows us to distinguish between Unions that have bodies and hence should UnionConverter
/// Allows us to distinguish Unions that do not have bodies and hence should use a TypeSafeEnumConverter
let hasOnlyNullaryCases (t : Type) =
let union = getInfo t
union.cases |> Seq.forall (fun case -> case.GetFields().Length = 0)
Expand All @@ -69,8 +69,8 @@ type UnionConverter<'T>() =
let union = Union.getInfo typeof<'T>
let unionOptions = getOptions union
let tag = union.tagReader value
let case = union.cases.[tag]
let fieldValues = union.fieldReader.[tag] value
let case = union.cases[tag]
let fieldValues = union.fieldReader[tag] value
let fieldInfos = case.GetFields()

writer.WriteStartObject()
Expand Down Expand Up @@ -111,7 +111,7 @@ type UnionConverter<'T>() =
inputCaseNameValue catchAllCaseName t.FullName |> invalidOp
| Some foundIndex -> foundIndex

let targetCaseFields, targetCaseCtor = union.cases.[targetCaseIndex].GetFields(), union.caseConstructor.[targetCaseIndex]
let targetCaseFields, targetCaseCtor = union.cases[targetCaseIndex].GetFields(), union.caseConstructor[targetCaseIndex]
let ctorArgs =
[| for fieldInfo in targetCaseFields ->
let t = fieldInfo.PropertyType
Expand Down

0 comments on commit 37a5731

Please sign in to comment.