Skip to content

Commit

Permalink
feat: add support for losslessOption
Browse files Browse the repository at this point in the history
Fix #81

Tag: core
  • Loading branch information
MangelMaxime committed Dec 29, 2024
1 parent 5da2bf5 commit 5f380a0
Show file tree
Hide file tree
Showing 11 changed files with 445 additions and 25 deletions.
5 changes: 5 additions & 0 deletions packages/Thoth.Json.Core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ This changelog is generated using [EasyBuild.ChangelogGen](https://github.com/ea

* Add `resizeArray` support ([GH-182](https://github.com/thoth-org/Thoth.Json/issues/182))
* Add `IEncoderHelpers.encodeResizeArray` ([GH-199](https://github.com/thoth-org/Thoth.Json/issues/199))
* Add `Decode.losslessOption`, `Decode.lossyOption` ([GH-81](https://github.com/thoth-org/Thoth.Json/issues/81))

### Changed

Expand All @@ -43,6 +44,10 @@ This changelog is generated using [EasyBuild.ChangelogGen](https://github.com/ea

* Encoding negative integers should keep their sign ([GH-187](https://github.com/thoth-org/Thoth.Json/issues/187))

### Removed

* Remove `Decode.option`, use `Decode.lossyOption` ([GH-81](https://github.com/thoth-org/Thoth.Json/issues/81))

## 0.2.1 - 2023-12-12

### Fixed
Expand Down
72 changes: 61 additions & 11 deletions packages/Thoth.Json.Core/Decode.fs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Thoth.Json.Core

open Fable.Core
open System
open System.Globalization

[<RequireQualifiedAccess>]
Expand Down Expand Up @@ -636,16 +636,6 @@ module Decode =
("", BadPrimitive("an array", value)) |> Error
}


let option (decoder: Decoder<'value>) : Decoder<'value option> =
{ new Decoder<'value option> with
member _.Decode(helpers, value) =
if helpers.isNullValue value then
Ok None
else
decoder.Decode(helpers, value) |> Result.map Some
}

//////////////////////
// Data structure ///
////////////////////
Expand Down Expand Up @@ -1065,6 +1055,66 @@ module Decode =
| _, _, _, _, _, _, _, Error er -> Error er
}

///////////////////////
// Option decoders //
///////////////////////

/// <summary>
/// Decode a JSON null value into an F# option.
///
/// Attention, this decoder is lossy, it will not be able to distinguish between `'T option` and `'T option option`.
///
/// If you need to distinguish between `'T option` and `'T option option`, use `losslessOption`.
/// </summary>
/// <param name="decoder">
/// The decoder to apply to the value if it is not null.
/// </param>
/// <typeparam name="'value">The type of the value to decode.</typeparam>
/// <returns>
/// <c>None</c> if the value is null, otherwise <c>Some value</c> where <c>value</c> is the result of the decoder.
/// </returns>
let lossyOption (decoder: Decoder<'value>) : Decoder<'value option> =
{ new Decoder<'value option> with
member _.Decode(helpers, value) =
if helpers.isNullValue value then
Ok None
else
decoder.Decode(helpers, value) |> Result.map Some
}

/// <summary>
/// Decode a JSON null value into an F# option.
///
/// This decoder is lossless, it will be able to distinguish between `'T option` and `'T option option`.
///
/// If you don't need to distinguish between `'T option` and `'T option option`, you can use `lossyOption` which is more efficient.
/// </summary>
/// <param name="decoder">
/// The decoder to apply to the value if it is not null.
/// </param>
/// <typeparam name="'value">The type of the value to decode.</typeparam>
/// <returns>
/// <c>None</c> if the value is null, otherwise <c>Some value</c> where <c>value</c> is the result of the decoder.
/// </returns>
let losslessOption (decoder: Decoder<'value>) : Decoder<'value option> =
field "$type" string
|> andThen (fun typeName ->
match typeName with
| "option" ->
field "$case" string
|> andThen (fun state ->
match state with
| "none" -> succeed None
| "some" -> field "$value" decoder |> map Some
| _ ->
fail (
"Expecting a state field with value 'none' or 'some' but got "
+ state
)
)
| _ -> fail ("Expecting an Option type but got " + typeName)
)

//////////////////////
// Object builder ///
////////////////////
Expand Down
51 changes: 50 additions & 1 deletion packages/Thoth.Json.Core/Encode.fs
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,58 @@ module Encode =
=
LanguagePrimitives.EnumToValue value |> uint32

let option (encoder: Encoder<'a>) =
/// <summary>
/// Encodes an option value using the provided encoder.
///
/// Attention, this encoder is lossy, it's result will not be able to distinguish between `'T option` and `'T option option`.
///
/// If you need to distinguish between `'T option` and `'T option option`, use `losslessOption`.
/// </summary>
/// <param name="encoder">The encoder to apply if the value is Some</param>
/// <typeparam name="'a">The type of the value to encode</typeparam>
/// <returns>
/// The result of the encoder if the value is Some, otherwise nil
/// </returns>
let lossyOption (encoder: Encoder<'a>) =
Option.map encoder >> Option.defaultWith (fun _ -> nil)

/// <summary>
/// Encodes an option value using the provided encoder.
///
/// This encoder is lossless, it's result will be able to distinguish between `'T option` and `'T option option`.
///
/// If you don't need to distinguish between `'T option` and `'T option option`, use `lossyOption`.
/// </summary>
/// <param name="encoder">The encoder to apply if the value is Some</param>
/// <typeparam name="'a">The type of the value to encode</typeparam>
/// <returns>
/// If the value is Some, the object will have the following fields:
///
/// - `$type` field set to `option`
/// - `$case` field set to `some`
/// - `$value` field set to the result of the encoder.
///
/// If the value is None, the object will have the following fields:
///
/// - `$type` field set to `option`
/// - `$case` field set to `none`
/// </returns>
let losslessOption (encoder: Encoder<'a>) (value: 'a option) =
match value with
| Some v ->
object
[
"$type", string "option"
"$case", string "some"
"$value", encoder v
]
| None ->
object
[
"$type", string "option"
"$case", string "none"
]

let inline toJsonValue
(helpers: IEncoderHelpers<'JsonValue>)
(json: IEncodable)
Expand Down
1 change: 1 addition & 0 deletions tests/Thoth.Json.Tests.JavaScript/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ let main args =

Decoders.tests runner
Encoders.tests runner
BackAndForth.tests runner

]
|> Pyxpecto.runTests [||]
1 change: 1 addition & 0 deletions tests/Thoth.Json.Tests.Newtonsoft/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ let main args =
[
Decoders.tests runner
Encoders.tests runner
BackAndForth.tests runner

]
|> Pyxpecto.runTests [||]
1 change: 1 addition & 0 deletions tests/Thoth.Json.Tests.Python/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ let main args =
[
Decoders.tests runner
Encoders.tests runner
BackAndForth.tests runner
]
|> Pyxpecto.runTests [||]
89 changes: 89 additions & 0 deletions tests/Thoth.Json.Tests/BackAndForth.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
module Thoth.Json.Tests.BackAndForth

open Thoth.Json.Tests.Testing
open Thoth.Json.Core
open Fable.Pyxpecto

let tests (runner: TestRunner<_, _>) =
testList
"Thoth.Json - BackAndForth"
[

testCase "losslessOption is symmetric"
<| fun _ ->
// Simple Some 'T
let expected = Some 42

let json =
expected
|> Encode.losslessOption Encode.int
|> runner.Encode.toString 0

let decoded =
runner.Decode.fromString
(Decode.losslessOption Decode.int)
json

equal (Ok expected) decoded

// Simple None

let expected = None

let json =
expected
|> Encode.losslessOption Encode.int
|> runner.Encode.toString 0

let decoded =
runner.Decode.fromString
(Decode.losslessOption Decode.int)
json

equal (Ok expected) decoded

// Nested option with value

let expected = Some(Some(Some 42))

let json =
expected
|> Encode.losslessOption (
Encode.losslessOption (Encode.losslessOption Encode.int)
)
|> runner.Encode.toString 0

let decoded =
runner.Decode.fromString
(Decode.losslessOption (
Decode.losslessOption (
Decode.losslessOption Decode.int
)
))
json

equal (Ok expected) decoded

// Nested option with None

let expected = Some(Some None)

let json =
expected
|> Encode.losslessOption (
Encode.losslessOption (Encode.losslessOption Encode.int)
)
|> runner.Encode.toString 0

let decoded =
runner.Decode.fromString
(Decode.losslessOption (
Decode.losslessOption (
Decode.losslessOption Decode.int
)
))
json

equal (Ok expected) decoded

]
Loading

0 comments on commit 5f380a0

Please sign in to comment.