From 68a60eb8208882e325d359d47cd2a0897e36fdc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Pavl=C3=ADk?= Date: Thu, 20 Mar 2025 12:24:58 +0100 Subject: [PATCH 1/5] Add Access.values/0 and Access.keys/0 --- lib/elixir/lib/access.ex | 118 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/lib/elixir/lib/access.ex b/lib/elixir/lib/access.ex index e5cfdca15e..a83b149c4b 100644 --- a/lib/elixir/lib/access.ex +++ b/lib/elixir/lib/access.ex @@ -999,6 +999,124 @@ defmodule Access do raise ArgumentError, "Access.slice/1 expected a list, got: #{inspect(data)}" end + @doc """ + Returns a function that accesses all values in a map. + + The returned function is typically passed as an accessor to `Kernel.get_in/2`, + `Kernel.get_and_update_in/3`, and friends. + + See `keys/0` for a function that accesses all keys in a map. + + ## Examples + + iex> users = %{"john" => %{age: 27}, "meg" => %{age: 23}} + iex> get_in(users, [Access.values(), :age]) + [27, 23] + iex> update_in(users, [Access.values(), :age], fn age -> age + 1 end) + %{"john" => %{age: 28}, "meg" => %{age: 24}} + iex> put_in(users, [Access.values(), :planet], "Earth") + %{"john" => %{age: 27, planet: "Earth"}, "meg" => %{age: 23, planet: "Earth"}} + + By returning `:pop` from an accessor function, you can remove the accessed key and value + from the map: + + iex> require Integer + iex> numbers = %{one: 1, two: 2, three: 3, four: 4} + iex> get_and_update_in(numbers, [Access.values()], fn num -> + ...> if Integer.is_even(num), do: :pop, else: {num, to_string(num)} + ...> end) + {[1, 2, 3, 4], %{one: "1", three: "3"}} + + An error is raised if the accessed structure is not a map: + + iex> get_in([1, 2, 3], [Access.values()]) + ** (RuntimeError) Access.values/0 expected a map, got: [1, 2, 3] + """ + @spec values() :: Access.access_fun(data :: map(), current_value :: list()) + def values do + &values/3 + end + + defp values(:get, data = %{}, next) do + Enum.map(data, fn {_key, value} -> next.(value) end) + end + + defp values(:get_and_update, data = %{}, next) do + {reverse_gets, updated_data} = + Enum.reduce(data, {[], %{}}, fn {key, value}, {gets, data_acc} -> + case next.(value) do + {get, update} -> {[get | gets], Map.put(data_acc, key, update)} + :pop -> {[value | gets], data_acc} + end + end) + + {Enum.reverse(reverse_gets), updated_data} + end + + defp values(_op, data, _next) do + raise "Access.values/0 expected a map, got: #{inspect(data)}" + end + + @doc """ + Returns a function that accesses all keys in a map. + + The returned function is typically passed as an accessor to `Kernel.get_in/2`, + `Kernel.get_and_update_in/3`, and friends. + + Beware that returning the same key multiple times in `Kernel.put_in/3`, `Kernel.update_in/3`, + or `Kernel.get_and_update_in/3` will cause the previous values of the same key to be + overwritten as maps cannot have duplicate keys. + + See `values/0` for a function that accesses all values in a map. + + ## Examples + + iex> data = %{users: %{"john" => %{age: 27}, "meg" => %{age: 23}}} + iex> get_in(data, [:users, Access.keys()]) + ["john", "meg"] + iex> update_in(data, [:users, Access.keys()], fn name -> String.upcase(name) end) + %{users: %{"JOHN" => %{age: 27}, "MEG" => %{age: 23}}} + + By returning `:pop` from an accessor function, you can remove the accessed key and value + from the map: + + iex> require Integer + iex> numbers = %{1 => "one", 2 => "two", 3 => "three", 4 => "four"} + iex> get_and_update_in(numbers, [Access.keys()], fn num -> + ...> if Integer.is_even(num), do: :pop, else: {num, to_string(num)} + ...> end) + {[1, 2, 3, 4], %{"1" => "one", "3" => "three"}} + + An error is raised if the accessed structure is not a map: + + iex> get_in([1, 2, 3], [Access.keys()]) + ** (RuntimeError) Access.keys/0 expected a map, got: [1, 2, 3] + """ + @spec keys() :: Access.access_fun(data :: map(), current_value :: list()) + def keys do + &keys/3 + end + + defp keys(:get, data = %{}, next) do + Enum.map(data, fn {key, _value} -> next.(key) end) + end + + defp keys(:get_and_update, data = %{}, next) do + {reverse_gets, updated_data} = + Enum.reduce(data, {[], %{}}, fn {key, value}, {gets, data_acc} -> + case next.(key) do + {get, update} -> {[get | gets], Map.put(data_acc, update, value)} + :pop -> {[key | gets], data_acc} + end + end) + + {Enum.reverse(reverse_gets), updated_data} + end + + defp keys(_op, data, _next) do + raise "Access.keys/0 expected a map, got: #{inspect(data)}" + end + defp normalize_range(%Range{first: first, last: last, step: step}, list) when first < 0 or last < 0 do count = length(list) From f7705f4576e24713fda155af5984a958ccd2bc53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Pavl=C3=ADk?= Date: Thu, 20 Mar 2025 13:19:08 +0100 Subject: [PATCH 2/5] Add @doc since attributes --- lib/elixir/lib/access.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/elixir/lib/access.ex b/lib/elixir/lib/access.ex index a83b149c4b..1ad5dc02c0 100644 --- a/lib/elixir/lib/access.ex +++ b/lib/elixir/lib/access.ex @@ -1032,6 +1032,7 @@ defmodule Access do iex> get_in([1, 2, 3], [Access.values()]) ** (RuntimeError) Access.values/0 expected a map, got: [1, 2, 3] """ + @doc since: "1.19.0" @spec values() :: Access.access_fun(data :: map(), current_value :: list()) def values do &values/3 @@ -1092,6 +1093,7 @@ defmodule Access do iex> get_in([1, 2, 3], [Access.keys()]) ** (RuntimeError) Access.keys/0 expected a map, got: [1, 2, 3] """ + @doc since: "1.19.0" @spec keys() :: Access.access_fun(data :: map(), current_value :: list()) def keys do &keys/3 From b118ad633fab4cb84e54d9a8347e5f005ac612c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Pavl=C3=ADk?= Date: Thu, 20 Mar 2025 13:28:21 +0100 Subject: [PATCH 3/5] Drop keys/0 --- lib/elixir/lib/access.ex | 63 ---------------------------------------- 1 file changed, 63 deletions(-) diff --git a/lib/elixir/lib/access.ex b/lib/elixir/lib/access.ex index 1ad5dc02c0..6da2e30c58 100644 --- a/lib/elixir/lib/access.ex +++ b/lib/elixir/lib/access.ex @@ -1005,8 +1005,6 @@ defmodule Access do The returned function is typically passed as an accessor to `Kernel.get_in/2`, `Kernel.get_and_update_in/3`, and friends. - See `keys/0` for a function that accesses all keys in a map. - ## Examples iex> users = %{"john" => %{age: 27}, "meg" => %{age: 23}} @@ -1058,67 +1056,6 @@ defmodule Access do raise "Access.values/0 expected a map, got: #{inspect(data)}" end - @doc """ - Returns a function that accesses all keys in a map. - - The returned function is typically passed as an accessor to `Kernel.get_in/2`, - `Kernel.get_and_update_in/3`, and friends. - - Beware that returning the same key multiple times in `Kernel.put_in/3`, `Kernel.update_in/3`, - or `Kernel.get_and_update_in/3` will cause the previous values of the same key to be - overwritten as maps cannot have duplicate keys. - - See `values/0` for a function that accesses all values in a map. - - ## Examples - - iex> data = %{users: %{"john" => %{age: 27}, "meg" => %{age: 23}}} - iex> get_in(data, [:users, Access.keys()]) - ["john", "meg"] - iex> update_in(data, [:users, Access.keys()], fn name -> String.upcase(name) end) - %{users: %{"JOHN" => %{age: 27}, "MEG" => %{age: 23}}} - - By returning `:pop` from an accessor function, you can remove the accessed key and value - from the map: - - iex> require Integer - iex> numbers = %{1 => "one", 2 => "two", 3 => "three", 4 => "four"} - iex> get_and_update_in(numbers, [Access.keys()], fn num -> - ...> if Integer.is_even(num), do: :pop, else: {num, to_string(num)} - ...> end) - {[1, 2, 3, 4], %{"1" => "one", "3" => "three"}} - - An error is raised if the accessed structure is not a map: - - iex> get_in([1, 2, 3], [Access.keys()]) - ** (RuntimeError) Access.keys/0 expected a map, got: [1, 2, 3] - """ - @doc since: "1.19.0" - @spec keys() :: Access.access_fun(data :: map(), current_value :: list()) - def keys do - &keys/3 - end - - defp keys(:get, data = %{}, next) do - Enum.map(data, fn {key, _value} -> next.(key) end) - end - - defp keys(:get_and_update, data = %{}, next) do - {reverse_gets, updated_data} = - Enum.reduce(data, {[], %{}}, fn {key, value}, {gets, data_acc} -> - case next.(key) do - {get, update} -> {[get | gets], Map.put(data_acc, update, value)} - :pop -> {[key | gets], data_acc} - end - end) - - {Enum.reverse(reverse_gets), updated_data} - end - - defp keys(_op, data, _next) do - raise "Access.keys/0 expected a map, got: #{inspect(data)}" - end - defp normalize_range(%Range{first: first, last: last, step: step}, list) when first < 0 or last < 0 do count = length(list) From 9f0405a09abc08ff2e8eb86203226a8f76fb6a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Pavl=C3=ADk?= Date: Thu, 20 Mar 2025 14:31:04 +0100 Subject: [PATCH 4/5] Support for keyword lists + fix order in doctests + add unit tests --- lib/elixir/lib/access.ex | 50 ++++++++++++++++---- lib/elixir/test/elixir/access_test.exs | 64 ++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/lib/elixir/lib/access.ex b/lib/elixir/lib/access.ex index 6da2e30c58..f757326293 100644 --- a/lib/elixir/lib/access.ex +++ b/lib/elixir/lib/access.ex @@ -1000,7 +1000,7 @@ defmodule Access do end @doc """ - Returns a function that accesses all values in a map. + Returns a function that accesses all values in a map or a keyword list. The returned function is typically passed as an accessor to `Kernel.get_in/2`, `Kernel.get_and_update_in/3`, and friends. @@ -1008,30 +1008,36 @@ defmodule Access do ## Examples iex> users = %{"john" => %{age: 27}, "meg" => %{age: 23}} - iex> get_in(users, [Access.values(), :age]) - [27, 23] + iex> get_in(users, [Access.values(), :age]) |> Enum.sort() + [23, 27] iex> update_in(users, [Access.values(), :age], fn age -> age + 1 end) %{"john" => %{age: 28}, "meg" => %{age: 24}} iex> put_in(users, [Access.values(), :planet], "Earth") %{"john" => %{age: 27, planet: "Earth"}, "meg" => %{age: 23, planet: "Earth"}} + Values in keyword lists can be accessed as well: + + iex> users = [john: %{age: 27}, meg: %{age: 23}] + iex> get_and_update_in(users, [Access.values(), :age], fn age -> {age, age + 1} end) + {[27, 23], [john: %{age: 28}, meg: %{age: 24}]} + By returning `:pop` from an accessor function, you can remove the accessed key and value - from the map: + from the map or keyword list: iex> require Integer - iex> numbers = %{one: 1, two: 2, three: 3, four: 4} + iex> numbers = [one: 1, two: 2, three: 3, four: 4] iex> get_and_update_in(numbers, [Access.values()], fn num -> ...> if Integer.is_even(num), do: :pop, else: {num, to_string(num)} ...> end) - {[1, 2, 3, 4], %{one: "1", three: "3"}} + {[1, 2, 3, 4], [one: "1", three: "3"]} - An error is raised if the accessed structure is not a map: + An error is raised if the accessed structure is not a map nor a keyword list: iex> get_in([1, 2, 3], [Access.values()]) - ** (RuntimeError) Access.values/0 expected a map, got: [1, 2, 3] + ** (RuntimeError) Access.values/0 expected a map or a keyword list, got: [1, 2, 3] """ @doc since: "1.19.0" - @spec values() :: Access.access_fun(data :: map(), current_value :: list()) + @spec values() :: Access.access_fun(data :: map() | keyword(), current_value :: list()) def values do &values/3 end @@ -1052,8 +1058,32 @@ defmodule Access do {Enum.reverse(reverse_gets), updated_data} end + defp values(op, data = [], next) do + values_keyword(op, data, next) + end + + defp values(op, data = [{key, _value} | _tail], next) when is_atom(key) do + values_keyword(op, data, next) + end + defp values(_op, data, _next) do - raise "Access.values/0 expected a map, got: #{inspect(data)}" + raise "Access.values/0 expected a map or a keyword list, got: #{inspect(data)}" + end + + defp values_keyword(:get, data, next) do + Enum.map(data, fn {_key, value} -> next.(value) end) + end + + defp values_keyword(:get_and_update, data, next) do + {reverse_gets, reverse_updated_data} = + Enum.reduce(data, {[], []}, fn {key, value}, {gets, data_acc} -> + case next.(value) do + {get, update} -> {[get | gets], [{key, update} | data_acc]} + :pop -> {[value | gets], data_acc} + end + end) + + {Enum.reverse(reverse_gets), Enum.reverse(reverse_updated_data)} end defp normalize_range(%Range{first: first, last: last, step: step}, list) diff --git a/lib/elixir/test/elixir/access_test.exs b/lib/elixir/test/elixir/access_test.exs index 9ff5a0ce9f..e23102da63 100644 --- a/lib/elixir/test/elixir/access_test.exs +++ b/lib/elixir/test/elixir/access_test.exs @@ -292,4 +292,68 @@ defmodule AccessTest do assert get_in(input, [:list, Access.at!(0), :greeting]) == "hi" end end + + describe "values/0" do + @test_map %{a: 1, b: 2, c: 3, d: 4} + @test_list [a: 1, b: 2, c: 3, d: 4] + @error_msg_pattern ~r[^Access.values/0 expected a map or a keyword list, got: .*] + + test "retrieves values in a map" do + assert [1, 2, 3, 4] = get_in(@test_map, [Access.values()]) |> Enum.sort() + end + + test "retrieves values in a keyword list" do + assert [1, 2, 3, 4] = get_in(@test_list, [Access.values()]) + end + + test "gets and updates values in a map" do + assert {gets, %{a: 3, b: 4, c: 5, d: 6}} = + get_and_update_in(@test_map, [Access.values()], fn n -> {n + 1, n + 2} end) + + assert [2, 3, 4, 5] = Enum.sort(gets) + end + + test "gets and updates values in a keyword list" do + assert {[2, 3, 4, 5], [a: 3, b: 4, c: 5, d: 6]} = + get_and_update_in(@test_list, [Access.values()], fn n -> {n + 1, n + 2} end) + end + + test "pops values from a map" do + assert {gets, %{c: 4, d: 5}} = + get_and_update_in(@test_map, [Access.values()], fn n -> + if(n > 2, do: {-n, n + 1}, else: :pop) + end) + + assert [-4, -3, 1, 2] = Enum.sort(gets) + end + + test "pops values from a keyword list" do + assert {[1, 2, -3, -4], [c: 4, d: 5]} = + get_and_update_in(@test_list, [Access.values()], fn n -> + if(n > 2, do: {-n, n + 1}, else: :pop) + end) + end + + test "raises when not given a map or a keyword list" do + assert_raise RuntimeError, @error_msg_pattern, fn -> + get_in(123, [Access.values()]) + end + + assert_raise RuntimeError, @error_msg_pattern, fn -> + get_and_update_in(:some_atom, [Access.values()], fn x -> {x, x} end) + end + + assert_raise RuntimeError, @error_msg_pattern, fn -> + get_in([:a, :b, :c], [Access.values()]) + end + + assert_raise RuntimeError, @error_msg_pattern, fn -> + get_in([{:a, :b, :c}, {:d, :e, :f}], [Access.values()]) + end + + assert_raise RuntimeError, @error_msg_pattern, fn -> + get_in([{1, 2}, {3, 4}], [Access.values()]) + end + end + end end From 8b4aa0f717b112c2efa158a90f135bda8aefc02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Pavl=C3=ADk?= Date: Thu, 20 Mar 2025 16:31:22 +0100 Subject: [PATCH 5/5] Assert that all keys in a keyword list are atoms --- lib/elixir/lib/access.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/elixir/lib/access.ex b/lib/elixir/lib/access.ex index f757326293..292e76c1ff 100644 --- a/lib/elixir/lib/access.ex +++ b/lib/elixir/lib/access.ex @@ -1071,12 +1071,12 @@ defmodule Access do end defp values_keyword(:get, data, next) do - Enum.map(data, fn {_key, value} -> next.(value) end) + Enum.map(data, fn {key, value} when is_atom(key) -> next.(value) end) end defp values_keyword(:get_and_update, data, next) do {reverse_gets, reverse_updated_data} = - Enum.reduce(data, {[], []}, fn {key, value}, {gets, data_acc} -> + Enum.reduce(data, {[], []}, fn {key, value}, {gets, data_acc} when is_atom(key) -> case next.(value) do {get, update} -> {[get | gets], [{key, update} | data_acc]} :pop -> {[value | gets], data_acc}