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

Fix optionally_at to handle Nil mid-path #809

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
88 changes: 69 additions & 19 deletions src/gleam/dynamic/decode.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand Down Expand Up @@ -383,21 +378,14 @@ 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(
path: List(a),
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 {
[] -> {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)
})
}

Expand Down
29 changes: 29 additions & 0 deletions test/gleam/dynamic/decode_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down