From f1dbbd5ff796b26b5f6f612952bdd46ef152df61 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sat, 8 Mar 2025 11:31:46 -0800 Subject: [PATCH] add decode.nullable_subfield and tests Signed-off-by: James Hillyerd --- src/gleam/dynamic/decode.gleam | 55 ++++++++ test/gleam/dynamic/decode_test.gleam | 180 +++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) diff --git a/src/gleam/dynamic/decode.gleam b/src/gleam/dynamic/decode.gleam index b75bb195..b9593d5f 100644 --- a/src/gleam/dynamic/decode.gleam +++ b/src/gleam/dynamic/decode.gleam @@ -330,6 +330,20 @@ pub fn subfield( }) } +pub fn nullable_subfield( + field_path: List(name), + default: t, + field_decoder: Decoder(t), + next: fn(t) -> Decoder(final), +) -> Decoder(final) { + Decoder(function: fn(data) { + let #(out, errors1) = + nullable_index(field_path, [], default, field_decoder.function, data) + let #(out, errors2) = next(out).function(data) + #(out, list.append(errors1, errors2)) + }) +} + /// Run a decoder on a `Dynamic` value, decoding the value if it is of the /// desired type, or returning errors. /// @@ -423,6 +437,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. +fn nullable_index( + path: List(a), + position: List(a), + default: b, + inner: fn(Dynamic) -> #(b, List(DecodeError)), + data: Dynamic, +) -> #(b, List(DecodeError)) { + case is_null(data) { + True -> { + #(default, []) + } + + False -> { + case path { + [] -> { + inner(data) + |> push_path(list.reverse(position)) + } + + [key, ..path] -> { + case bare_index(data, key) { + Ok(Some(data)) -> { + nullable_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) diff --git a/test/gleam/dynamic/decode_test.gleam b/test/gleam/dynamic/decode_test.gleam index 080aba75..991ad173 100644 --- a/test/gleam/dynamic/decode_test.gleam +++ b/test/gleam/dynamic/decode_test.gleam @@ -912,6 +912,186 @@ pub fn optionally_at_no_path_error_test() { |> should.equal(100) } +pub fn nullable_subfield_ok_test() { + let data = + dynamic.from( + dict.from_list([ + #("person", dict.from_list([#("name", dynamic.from("Nubi"))])), + ]), + ) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + "default", + decode.string, + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal("Nubi") +} + +pub fn nullable_subfield_int_index_ok_test() { + let decoder = { + use x <- decode.nullable_subfield([0, 1], "default", decode.string) + use y <- decode.nullable_subfield([1, 0], "default", decode.string) + decode.success(#(x, y)) + } + + dynamic.from(#(#("one", "two", "three"), #("a", "b"))) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(#("two", "a")) +} + +pub fn nullable_subfield_nil_int_index_ok_test() { + let decoder = { + use x <- decode.nullable_subfield([0, 1, 1], "default", decode.string) + decode.success(x) + } + + dynamic.from(#(Nil, #("a", "b"))) + |> decode.run(decoder) + |> should.be_ok + |> should.equal("default") +} + +pub fn nullable_subfield_nil_in_path_ok_test() { + let data = dynamic.from(dict.from_list([#("person", dynamic.from(Nil))])) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + "default", + decode.string, + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal("default") +} + +pub fn nullable_subfield_path_not_found_ok_test() { + let data = dynamic.from(dict.from_list([])) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + "default", + decode.string, + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal("default") +} + +pub fn nullable_subfield_nil_target_ok_test() { + let data = + dynamic.from( + dict.from_list([ + #("person", dict.from_list([#("name", dynamic.from(Nil))])), + ]), + ) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + "default", + decode.string, + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal("default") +} + +// This test is probably overkill, just wanted to make sure it worked +pub fn nullable_subfield_optional_nil_target_ok_test() { + let data = + dynamic.from( + dict.from_list([ + #("person", dict.from_list([#("name", dynamic.from(Nil))])), + ]), + ) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + option.None, + decode.optional(decode.string), + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal(option.None) +} + +// This test is probably overkill, just wanted to make sure it worked +pub fn nullable_subfield_optional_some_target_ok_test() { + let data = + dynamic.from( + dict.from_list([ + #("person", dict.from_list([#("name", dynamic.from("Nubi"))])), + ]), + ) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + option.None, + decode.optional(decode.string), + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal(option.Some("Nubi")) +} + +pub fn nullable_subfield_path_type_error_test() { + let data = dynamic.from(dict.from_list([#("person", dynamic.from(123))])) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + "default", + decode.string, + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_error + |> should.equal([DecodeError("Dict", "Int", ["person"])]) +} + +pub fn nullable_subfield_target_type_error_test() { + let data = + dynamic.from( + dict.from_list([ + #("person", dict.from_list([#("name", dynamic.from(123))])), + ]), + ) + let decoder = { + use name <- decode.nullable_subfield( + ["person", "name"], + "default", + decode.string, + ) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["person", "name"])]) +} + @external(erlang, "maps", "from_list") @external(javascript, "../../gleam_stdlib_test_ffi.mjs", "object") fn make_object(items: List(#(String, t))) -> Dynamic