Skip to content

Commit dfc6eb4

Browse files
authored
Merge pull request #95 from xperiandri/master
Implemented ability to deserialize discriminated unions regardless of union tag position
2 parents bebdc81 + bf04dce commit dfc6eb4

6 files changed

Lines changed: 128 additions & 26 deletions

File tree

build.fsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Target.create "TestTrim" <| fun _ ->
9393
/// project(s) as part of the run.
9494
Target.create "Benchmark" (fun _ ->
9595
DotNet.exec (fun o -> { o with
96-
WorkingDirectory = Paths.benchmarks } ) "run" "-c release --filter \"*\""
96+
WorkingDirectory = Paths.benchmarks } ) "run" "-c release --runtimes netcoreapp50 --filter \"*\""
9797
|> checkOk "Benchmarks"
9898
)
9999

docs/Customizing.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,27 @@ type Location =
406406
// Instead of {"Item":{"lat":48.858,"long":2.295}}
407407
```
408408
409+
#### `AllowUnorderedTag`
410+
411+
`JsonUnionEncoding.AllowUnorderedTag` is enabled by default.
412+
It takes effect during deserialization in AdjacentTag and InternalTag modes.
413+
When it is disabled, the name of the case must be the first field of the JSON object.
414+
When it is enabled, the name of the case may come later in the object, at the cost of a slight performance penalty if it does.
415+
416+
For example, without `AllowUnorderedTag`, the following will fail to parse:
417+
418+
```fsharp
419+
JsonSerializer.Deserialize("""{"Fields":[3.14],"Case":"WithOneArg"}""", options)
420+
// --> Error: Failed to find union case field for Example: expected Case
421+
```
422+
423+
Whereas with `AllowUnorderedTag`, it will succeed:
424+
425+
```fsharp
426+
JsonSerializer.Deserialize("""{"Fields":[3.14],"Case":"WithOneArg"}""", options)
427+
// --> WithOneArg 3.14
428+
```
429+
409430
### Combined flags
410431

411432
`JsonUnionEncoding` also contains a few items that combine several of the above flags.
@@ -417,6 +438,7 @@ type Location =
417438
JsonUnionEncoding.AdjacentTag
418439
||| JsonUnionEncoding.UnwrapOption
419440
||| JsonUnionEncoding.UnwrapSingleCaseUnions
441+
||| JsonUnionEncoding.AllowUnorderedTag
420442
```
421443
422444
It is particularly useful if you want to use the default encoding with some additional options, for example:
@@ -430,6 +452,7 @@ type Location =
430452
431453
```fsharp
432454
JsonUnionEncoding.AdjacentTag
455+
||| JsonUnionEncoding.AllowUnorderedTag
433456
```
434457
435458
* `JsonUnionEncoding.ThothLike` causes similar behavior to the library [Thoth.Json](https://thoth-org.github.io/Thoth.Json/).
@@ -438,6 +461,7 @@ type Location =
438461
```fsharp
439462
JsonUnionEncoding.InternalTag
440463
||| JsonUnionEncoding.UnwrapFieldlessTags
464+
||| JsonUnionEncoding.AllowUnorderedTag
441465
```
442466
443467
* `JsonUnionEncoding.FSharpLuLike` causes similar behavior to the library [FSharpLu.Json](https://github.com/microsoft/fsharplu/wiki/FSharpLu.Json) in Compact mode.
@@ -448,6 +472,7 @@ type Location =
448472
||| JsonUnionEncoding.UnwrapFieldlessTags
449473
||| JsonUnionEncoding.UnwrapOption
450474
||| JsonUnionEncoding.UnwrapSingleFieldCases
475+
||| JsonUnionEncoding.AllowUnorderedTag
451476
```
452477
453478
## `unionTagName`

src/FSharp.SystemTextJson/Helpers.fs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ let readExpecting expectedTokenType expectedLabel (reader: byref<Utf8JsonReader>
1919
if not (reader.Read()) || reader.TokenType <> expectedTokenType then
2020
fail expectedLabel &reader ty
2121

22+
let inline readIsExpectingPropertyNamed (expectedPropertyName: string) (reader: byref<Utf8JsonReader>) ty =
23+
(reader.Read()) && reader.TokenType = JsonTokenType.PropertyName && (reader.ValueTextEquals expectedPropertyName)
24+
2225
let readExpectingPropertyNamed (expectedPropertyName: string) (reader: byref<Utf8JsonReader>) ty =
23-
if not (reader.Read()) || reader.TokenType <> JsonTokenType.PropertyName || not (reader.ValueTextEquals expectedPropertyName) then
26+
if not <| readIsExpectingPropertyNamed expectedPropertyName &reader ty then
2427
fail ("\"" + expectedPropertyName + "\"") &reader ty
2528

2629
let isNullableUnion (ty: Type) =

src/FSharp.SystemTextJson/Options.fs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,17 @@ type JsonUnionEncoding =
7777
/// the fields of this record are encoded directly as fields of the object representing the union.
7878
| UnwrapRecordCases = 0x00_00_21_00
7979

80+
/// In AdjacentTag and InternalTag mode, allow deserializing unions
81+
/// where the tag is not the first field in the JSON object.
82+
| AllowUnorderedTag = 0x00_00_40_00
83+
8084

8185
//// Specific formats
8286

83-
| Default = 0x00_00_0C_01
84-
| NewtonsoftLike = 0x00_00_00_01
85-
| ThothLike = 0x00_00_02_04
86-
| FSharpLuLike = 0x00_00_16_02
87+
| Default = 0x00_00_4C_01 // AdjacentTag ||| UnwrapOption ||| UnwrapSingleCaseUnions ||| AllowUnorderedTag
88+
| NewtonsoftLike = 0x00_00_40_01 // AdjacentTag ||| AllowUnorderedTag
89+
| ThothLike = 0x00_00_42_04 // InternalTag ||| BareFieldlessTags ||| AllowUnorderedTag
90+
| FSharpLuLike = 0x00_00_56_02 // ExternalTag ||| BareFieldlessTags ||| UnwrapOption ||| UnwrapSingleFieldCases ||| AllowUnorderedTag
8791

8892
type JsonUnionTagName = string
8993
type JsonUnionFieldsName = string

src/FSharp.SystemTextJson/Union.fs

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ open System.Collections.Generic
55
open System.Text.Json
66
open FSharp.Reflection
77
open System.Text.Json.Serialization.Helpers
8+
open System.Buffers
89

910
type private Field =
1011
{
@@ -153,7 +154,7 @@ type JsonUnionConverter<'T>
153154
else
154155
ValueNone
155156

156-
let getCaseByTag (reader: byref<Utf8JsonReader>) =
157+
let getCaseByTagReader (reader: byref<Utf8JsonReader>) =
157158
let found =
158159
match casesByName with
159160
| ValueNone ->
@@ -176,6 +177,29 @@ type JsonUnionConverter<'T>
176177
| ValueSome case ->
177178
case
178179

180+
let getCaseByTagString tag =
181+
let found =
182+
match casesByName with
183+
| ValueNone ->
184+
let mutable found = ValueNone
185+
let mutable i = 0
186+
while found.IsNone && i < cases.Length do
187+
let case = cases.[i]
188+
if case.Name.Equals(tag, StringComparison.OrdinalIgnoreCase) then
189+
found <- ValueSome case
190+
else
191+
i <- i + 1
192+
found
193+
| ValueSome d ->
194+
match d.TryGetValue(tag) with
195+
| true, c -> ValueSome c
196+
| false, _ -> ValueNone
197+
match found with
198+
| ValueNone ->
199+
raise (JsonException("Unknown case for union type " + ty.FullName + ": " + tag))
200+
| ValueSome case ->
201+
case
202+
179203
let getCaseByFieldName (reader: byref<Utf8JsonReader>) =
180204
let found =
181205
match allFieldsByName with
@@ -286,39 +310,61 @@ type JsonUnionConverter<'T>
286310
else
287311
readFieldsAsArray &reader case options
288312

313+
let getCaseFromDocument (reader: Utf8JsonReader) =
314+
let mutable reader = reader
315+
let document = JsonDocument.ParseValue(&reader)
316+
match document.RootElement.TryGetProperty fsOptions.UnionTagName with
317+
| true, element -> getCaseByTagString (element.GetString())
318+
| false, _ ->
319+
sprintf "Failed to find union case field for %s: expected %s" ty.FullName fsOptions.UnionTagName
320+
|> JsonException
321+
|> raise
322+
323+
let getCase (reader: byref<Utf8JsonReader>) =
324+
let mutable snapshot = reader
325+
if readIsExpectingPropertyNamed fsOptions.UnionTagName &snapshot ty then
326+
readExpectingPropertyNamed fsOptions.UnionTagName &reader ty
327+
readExpecting JsonTokenType.String "case name" &reader ty
328+
struct (getCaseByTagReader &reader, false)
329+
elif fsOptions.UnionEncoding.HasFlag JsonUnionEncoding.AllowUnorderedTag then
330+
struct (getCaseFromDocument reader, true)
331+
else
332+
sprintf "Failed to find union case field for %s: expected %s" ty.FullName fsOptions.UnionTagName
333+
|> JsonException
334+
|> raise
335+
289336
let readAdjacentTag (reader: byref<Utf8JsonReader>) (options: JsonSerializerOptions) =
290337
expectAlreadyRead JsonTokenType.StartObject "object" &reader ty
291-
readExpectingPropertyNamed fsOptions.UnionTagName &reader ty
292-
readExpecting JsonTokenType.String "case name" &reader ty
293-
let case = getCaseByTag &reader
338+
let struct (case, usedDocument) = getCase &reader
294339
let res =
295340
if case.Fields.Length > 0 then
296341
readExpectingPropertyNamed fsOptions.UnionFieldsName &reader ty
297342
readFields &reader case options
298343
else
299344
case.Ctor [||] :?> 'T
345+
if usedDocument then
346+
reader.Read() |> ignore
347+
reader.Skip()
300348
readExpecting JsonTokenType.EndObject "end of object" &reader ty
301349
res
302350

303351
let readExternalTag (reader: byref<Utf8JsonReader>) (options: JsonSerializerOptions) =
304352
expectAlreadyRead JsonTokenType.StartObject "object" &reader ty
305353
readExpecting JsonTokenType.PropertyName "case name" &reader ty
306-
let case = getCaseByTag &reader
354+
let case = getCaseByTagReader &reader
307355
let res = readFields &reader case options
308356
readExpecting JsonTokenType.EndObject "end of object" &reader ty
309357
res
310358

311359
let readInternalTag (reader: byref<Utf8JsonReader>) (options: JsonSerializerOptions) =
312360
if namedFields then
313361
expectAlreadyRead JsonTokenType.StartObject "object" &reader ty
314-
readExpectingPropertyNamed fsOptions.UnionTagName &reader ty
315-
readExpecting JsonTokenType.String "case name" &reader ty
316-
let case = getCaseByTag &reader
362+
let struct (case, usedDocument) = getCase &reader
317363
readFieldsAsRestOfObject &reader case false options
318364
else
319365
expectAlreadyRead JsonTokenType.StartArray "array" &reader ty
320366
readExpecting JsonTokenType.String "case name" &reader ty
321-
let case = getCaseByTag &reader
367+
let case = getCaseByTagReader &reader
322368
readFieldsAsRestOfArray &reader case options
323369

324370
let readUntagged (reader: byref<Utf8JsonReader>) (options: JsonSerializerOptions) =
@@ -408,7 +454,7 @@ type JsonUnionConverter<'T>
408454
| JsonTokenType.Null when Helpers.isNullableUnion ty ->
409455
(null : obj) :?> 'T
410456
| JsonTokenType.String when unwrapFieldlessTags ->
411-
let case = getCaseByTag &reader
457+
let case = getCaseByTagReader &reader
412458
case.Ctor [||] :?> 'T
413459
| _ ->
414460
match baseFormat with

0 commit comments

Comments
 (0)