diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a69d026..88d44058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Improved `optionally_at` in `dynamic/decode` to decode nil values encountered + along its path to the default value. + ## v0.55.0 - 2025-02-21 - The performance of `dict.is_empty` has been improved. diff --git a/src/gleam/dynamic/decode.gleam b/src/gleam/dynamic/decode.gleam index b75bb195..9072d58f 100644 --- a/src/gleam/dynamic/decode.gleam +++ b/src/gleam/dynamic/decode.gleam @@ -319,12 +319,7 @@ pub fn subfield( next: fn(t) -> Decoder(final), ) -> Decoder(final) { Decoder(function: fn(data) { - let #(out, errors1) = - index(field_path, [], field_decoder.function, data, fn(data, position) { - let #(default, _) = field_decoder.function(data) - #(default, [DecodeError("Field", "Nothing", [])]) - |> push_path(list.reverse(position)) - }) + let #(out, errors1) = index(field_path, [], field_decoder.function, data) let #(out, errors2) = next(out).function(data) #(out, list.append(errors1, errors2)) }) @@ -383,13 +378,7 @@ pub fn run(data: Dynamic, decoder: Decoder(t)) -> Result(t, List(DecodeError)) { /// ``` /// pub fn at(path: List(segment), inner: Decoder(a)) -> Decoder(a) { - Decoder(function: fn(data) { - index(path, [], inner.function, data, fn(data, position) { - let #(default, _) = inner.function(data) - #(default, [DecodeError("Field", "Nothing", [])]) - |> push_path(list.reverse(position)) - }) - }) + Decoder(function: fn(data) { index(path, [], inner.function, data) }) } fn index( @@ -397,7 +386,6 @@ fn index( position: List(a), inner: fn(Dynamic) -> #(b, List(DecodeError)), data: Dynamic, - handle_miss: fn(Dynamic, List(a)) -> #(b, List(DecodeError)), ) -> #(b, List(DecodeError)) { case path { [] -> { @@ -408,10 +396,12 @@ fn index( [key, ..path] -> { case bare_index(data, key) { Ok(Some(data)) -> { - index(path, [key, ..position], inner, data, handle_miss) + index(path, [key, ..position], inner, data) } Ok(None) -> { - handle_miss(data, [key, ..position]) + let #(default, _) = inner(data) + #(default, [DecodeError("Field", "Nothing", [])]) + |> push_path(list.reverse([key, ..position])) } Error(kind) -> { let #(default, _) = inner(data) @@ -423,6 +413,47 @@ fn index( } } +// Indexes into a path similar to `index`. It will decode to default instead +// of an error if a Nil value is encountered, except for the final path +// element. +fn optional_index( + path: List(a), + position: List(a), + default: b, + inner: fn(Dynamic) -> #(b, List(DecodeError)), + data: Dynamic, +) -> #(b, List(DecodeError)) { + case path { + [] -> { + inner(data) + |> push_path(list.reverse(position)) + } + + [key, ..path] -> { + case is_null(data) { + True -> { + #(default, []) + } + False -> { + case bare_index(data, key) { + Ok(Some(data)) -> { + optional_index(path, [key, ..position], default, inner, data) + } + Ok(None) -> { + #(default, []) + } + Error(kind) -> { + let #(default, _) = inner(data) + #(default, [DecodeError(kind, dynamic.classify(data), [])]) + |> push_path(list.reverse(position)) + } + } + } + } + } + } +} + @external(erlang, "gleam_stdlib_decode_ffi", "index") @external(javascript, "../../gleam_stdlib_decode_ffi.mjs", "index") fn bare_index(data: Dynamic, key: anything) -> Result(Option(Dynamic), String) @@ -572,6 +603,10 @@ pub fn optional_field( /// an int then it'll also index into Erlang tuples and JavaScript arrays, and /// the first two elements of Gleam lists. /// +/// If a nil value is encountered along the path, except at the final position, +/// the provided default will be returned. The `optional` function may be used +/// to accept a nil value at the final location the path points to. +/// /// # Examples /// /// ```gleam @@ -581,9 +616,24 @@ pub fn optional_field( /// #("one", dict.from_list([])), /// ])) /// +/// let result = decode.run(data, decoder) +/// assert result == Ok(100) +/// /// -/// decode.run(data, decoder) -/// // -> Ok(100) +/// let decoder = +/// decode.optionally_at( +/// ["first", "second", "third"], +/// option.None, +/// decode.optional(decode.int), +/// ) +/// +/// let data = +/// dynamic.from( +/// dict.from_list([#("first", dict.from_list([#("second", Nil)]))]), +/// ) +/// +/// let result = decode.run(data, decoder) +/// assert result == Ok(option.None) /// ``` /// pub fn optionally_at( @@ -592,7 +642,7 @@ pub fn optionally_at( inner: Decoder(a), ) -> Decoder(a) { Decoder(function: fn(data) { - index(path, [], inner.function, data, fn(_, _) { #(default, []) }) + optional_index(path, [], default, inner.function, data) }) } diff --git a/test/gleam/dynamic/decode_test.gleam b/test/gleam/dynamic/decode_test.gleam index 080aba75..bb501957 100644 --- a/test/gleam/dynamic/decode_test.gleam +++ b/test/gleam/dynamic/decode_test.gleam @@ -912,6 +912,35 @@ pub fn optionally_at_no_path_error_test() { |> should.equal(100) } +pub fn optionally_at_nil_in_path_error_test() { + dynamic.from(dict.from_list([#("first", dict.from_list([#("second", Nil)]))])) + |> decode.run(decode.optionally_at( + ["first", "second", "third"], + 100, + decode.int, + )) + |> should.be_ok + |> should.equal(100) +} + +pub fn optionally_at_nil_target_error_test() { + dynamic.from( + dict.from_list([ + #( + "first", + dict.from_list([#("second", dict.from_list([#("third", Nil)]))]), + ), + ]), + ) + |> decode.run(decode.optionally_at( + ["first", "second", "third"], + 100, + decode.int, + )) + |> should.be_error + |> should.equal([DecodeError("Int", "Nil", ["first", "second", "third"])]) +} + @external(erlang, "maps", "from_list") @external(javascript, "../../gleam_stdlib_test_ffi.mjs", "object") fn make_object(items: List(#(String, t))) -> Dynamic