├── test ├── test_helper.exs └── atomic_map_test.exs ├── .gitignore ├── CHANGELOG.md ├── .travis.yml ├── bench ├── snapshots │ └── 2016-05-04_22-21-57.snapshot └── basic_bench.exs ├── mix.lock ├── config └── config.exs ├── mix.exs ├── lib └── atomic_map.ex └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | doc/* 7 | .elixir_ls 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## CHANGELOG 2 | v0.9.3: 3 | - underscore works with numbers 4 | 5 | v0.9.0: 6 | - underscore replaces now hyphens (https://github.com/ruby2elixir/atomic_map/pull/5 + https://github.com/ruby2elixir/atomic_map/pull/6) 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 1.2.0 3 | notifications: 4 | recipients: 5 | - roman.heinrich@gmail.comm 6 | otp_release: 7 | - 18.0 8 | script: "MIX_ENV=test mix local.hex --force && MIX_ENV=test mix do deps.get, test" 9 | -------------------------------------------------------------------------------- /bench/snapshots/2016-05-04_22-21-57.snapshot: -------------------------------------------------------------------------------- 1 | duration:1.0;mem stats:false;sys mem stats:false 2 | module;test;tags;iterations;elapsed 3 | BasicBench regex_underscore - short 100000 2667305 4 | BasicBench regex_underscore - long 20000 1656602 5 | BasicBench macro_underscore - short 100000 1347365 6 | BasicBench macro_underscore - long 50000 2556233 7 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"benchfella": {:hex, :benchfella, "0.3.2", "b9648e77fa8d8b8b9fe8f54293bee63f7de03909b3af6ab22a0e546716a396fb", [:mix], []}, 2 | "earmark": {:hex, :earmark, "1.1.1", "433136b7f2e99cde88b745b3a0cfc3fbc81fe58b918a09b40fce7f00db4d8187", [:mix], []}, 3 | "ex_doc": {:hex, :ex_doc, "0.15.0", "e73333785eef3488cf9144a6e847d3d647e67d02bd6fdac500687854dd5c599f", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}} 4 | -------------------------------------------------------------------------------- /bench/basic_bench.exs: -------------------------------------------------------------------------------- 1 | defmodule BasicBench do 2 | use Benchfella 3 | 4 | def macro_underscore(s) do 5 | s 6 | |> Macro.underscore 7 | |> String.replace(~r/-/, "_") 8 | end 9 | 10 | def regex_underscore(s) do 11 | s 12 | |> String.replace(~r/([A-Z]+)([A-Z][a-z])/, "\\1_\\2") 13 | |> String.replace(~r/([a-z\d])([A-Z])/, "\\1_\\2") 14 | |> String.replace(~r/-/, "_") 15 | |> String.downcase 16 | end 17 | 18 | @long_string "StringShouldChange-some_stuffStringShouldChange-some_stuffStringShouldChange-some_stuffStringShouldChange-some_stuffStringShouldChange-some_stuff" 19 | @short_string "StringShouldChange-some_stuff" 20 | 21 | bench "macro_underscore - long" do 22 | @long_string |> macro_underscore 23 | end 24 | 25 | bench "regex_underscore - long" do 26 | @long_string |> regex_underscore 27 | end 28 | 29 | bench "macro_underscore - short" do 30 | @short_string |> macro_underscore 31 | end 32 | 33 | bench "regex_underscore - short" do 34 | @short_string |> regex_underscore 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :atomic_map, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:atomic_map, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicMap.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.9.2" 5 | def project do 6 | [app: :atomic_map, 7 | version: @version, 8 | elixir: ">= 1.2.0", 9 | build_embedded: Mix.env == :prod, 10 | start_permanent: Mix.env == :prod, 11 | package: package(), 12 | docs: [extras: ["README.md"]], 13 | deps: deps()] 14 | end 15 | 16 | # Configuration for the OTP application 17 | # 18 | # Type "mix help compile.app" for more information 19 | def application do 20 | [applications: [:logger]] 21 | end 22 | 23 | # Dependencies can be Hex packages: 24 | # 25 | # {:mydep, "~> 0.3.0"} 26 | # 27 | # Or git/path repositories: 28 | # 29 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 30 | # 31 | # Type "mix help deps" for more examples and options 32 | defp deps do 33 | [ 34 | {:earmark, "~> 1.1", only: :dev}, 35 | {:ex_doc, "~> 0.14", only: :dev}, 36 | {:benchfella, "~> 0.3", only: :dev}, 37 | ] 38 | end 39 | 40 | defp package do 41 | [ 42 | maintainers: ["Roman Heinrich"], 43 | licenses: ["MIT License"], 44 | description: "A small utility to convert deep Elixir maps with mixed string/atom keys to atom-only keyed maps", 45 | links: %{ 46 | github: "https://github.com/ruby2elixir/atomic_map", 47 | docs: "http://hexdocs.pm/atomic_map/#{@version}/" 48 | } 49 | ] 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/atomic_map.ex: -------------------------------------------------------------------------------- 1 | defmodule AtomicMap.Opts do 2 | @moduledoc ~S""" 3 | Set any value to `false` to disable checking for that kind of key. 4 | """ 5 | defstruct safe: true, 6 | underscore: true, 7 | ignore: false 8 | end 9 | 10 | defmodule AtomicMap do 11 | def convert(v, opts \\ %{}) 12 | 13 | def convert(struct=%{__struct__: type}, opts=%AtomicMap.Opts{}) do 14 | struct 15 | |> Map.from_struct() 16 | |> convert(opts) 17 | |> Map.put(:__struct__, type) 18 | end 19 | def convert(map, opts=%AtomicMap.Opts{}) when is_map(map) do 20 | map |> Enum.reduce(%{}, fn({k,v}, acc)-> 21 | k = k |> convert_key(opts) 22 | v = v |> convert(opts) 23 | acc |> Map.put(k, v) 24 | end) 25 | end 26 | def convert(list, opts=%AtomicMap.Opts{}) when is_list(list) do 27 | list |> Enum.map(fn(x)-> convert(x, opts) end) 28 | end 29 | def convert(tuple, opts=%AtomicMap.Opts{}) when is_tuple(tuple) do 30 | tuple |> Tuple.to_list |> convert(opts) |> List.to_tuple() 31 | end 32 | def convert(v, _opts=%AtomicMap.Opts{}), do: v 33 | 34 | # if you pass a plain map or keyword list as opts, those will match and convert it to struct 35 | def convert(v, opts=%{}), do: convert(v, struct(AtomicMap.Opts, opts)) 36 | def convert(v, opts) when is_list(opts), do: convert(v, Enum.into(opts, %{})) 37 | 38 | defp convert_key(k, opts) do 39 | k 40 | |> as_underscore(opts.underscore) 41 | |> as_atom(opts.safe, opts.ignore) 42 | end 43 | 44 | # params: key, safe, ignore 45 | defp as_atom(s, true, true) when is_binary(s) do 46 | try do 47 | as_atom(s, true, false) 48 | rescue 49 | ArgumentError -> s 50 | end 51 | end 52 | defp as_atom(s, true, false) when is_binary(s) do 53 | s |> String.to_existing_atom() 54 | end 55 | defp as_atom(s, false, _) when is_binary(s), do: s |> String.to_atom() 56 | defp as_atom(s, _, _), do: s 57 | 58 | defp as_underscore(s, true) when is_number(s), do: s 59 | defp as_underscore(s, true) when is_binary(s), do: s |> do_underscore() 60 | defp as_underscore(s, true) when is_atom(s), do: s |> Atom.to_string() |> as_underscore(true) 61 | defp as_underscore(s, false), do: s 62 | 63 | defp do_underscore(s) do 64 | s 65 | |> Macro.underscore() 66 | |> String.replace(~r/-/, "_") 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/atomic_map_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AtomicMapTest do 2 | use ExUnit.Case 3 | doctest AtomicMap 4 | 5 | defmodule MyStruct do 6 | defstruct first: nil, second: nil 7 | end 8 | 9 | test "works with maps" do 10 | input = %{"a" => 2, "b" => %{"c" => 4}} 11 | expected = %{a: 2, b: %{c: 4}} 12 | assert AtomicMap.convert(input, %{safe: true}) == expected 13 | end 14 | 15 | test "works with maps with lists" do 16 | input = %{ "a" => [ %{"c" => 1}, %{"c" => 2}] } 17 | expected = %{a: [%{c: 1}, %{c: 2}] } 18 | assert AtomicMap.convert(input, safe: true) == expected 19 | end 20 | 21 | test "works with lists with maps" do 22 | input = [ %{"c" => 1}, %{"c" => 2}, %{"c" => %{"b" => 4}}] 23 | expected = [%{c: 1}, %{c: 2}, %{c: %{b: 4}}] 24 | assert AtomicMap.convert(input, safe: true) == expected 25 | end 26 | 27 | test "works with numbers as keys" do 28 | input = [ %{200 => 1}, %{"c" => 2}, %{"c" => %{"b" => 4}}] 29 | expected = [%{200 => 1}, %{c: 2}, %{c: %{b: 4}}] 30 | assert AtomicMap.convert(input, safe: true) == expected 31 | end 32 | 33 | test "works with mixed keys (string / atoms)" do 34 | input = [ %{"c" => 1}, %{:c => 2}, %{"c" => %{:b => 4}}] 35 | expected = [%{c: 1}, %{c: 2}, %{c: %{b: 4}}] 36 | assert AtomicMap.convert(input, safe: true) == expected 37 | end 38 | 39 | test "works with simple values" do 40 | input = "2" 41 | expected = "2" 42 | assert AtomicMap.convert(input, safe: true) == expected 43 | end 44 | 45 | test "works with default opts" do 46 | input = "2" 47 | expected = "2" 48 | assert AtomicMap.convert(input) == expected 49 | end 50 | 51 | test "works with structs" do 52 | input = %AtomicMapTest.MyStruct{first: [%{"a" => 1}]} 53 | expected = %AtomicMapTest.MyStruct{first: [%{a: 1}]} 54 | assert AtomicMap.convert(input) == expected 55 | end 56 | 57 | test "works with nested structs" do 58 | input = %AtomicMapTest.MyStruct{first: [%AtomicMapTest.MyStruct{first: %{"b" => 1}}]} 59 | expected = %AtomicMapTest.MyStruct{first: [%AtomicMapTest.MyStruct{first: %{b: 1}}]} 60 | assert AtomicMap.convert(input) == expected 61 | end 62 | 63 | test "works with tuples" do 64 | input = %{ "first" => {1,2}} 65 | expected = %{first: {1, 2}} 66 | assert AtomicMap.convert(input) == expected 67 | end 68 | 69 | test "converts keys to underscore by default (attention: safe: false needed here)" do 70 | input = %{ "firstKey" => {1,2}, :secondKey => 4} 71 | expected = %{first_key: {1, 2}, second_key: 4} 72 | assert AtomicMap.convert(input, safe: false) == expected 73 | end 74 | 75 | test "underscore flag works with hyphens" do 76 | input = %{ "first-key" => {1,2}} 77 | expected = %{first_key: {1, 2}} 78 | assert AtomicMap.convert(input, safe: false) == expected 79 | end 80 | 81 | test "skips not existing atoms when ignore = true" do 82 | input = %{"unknow_val" => 2} 83 | expected = %{"unknow_val" => 2} 84 | assert AtomicMap.convert(input, %{safe: true, ignore: true}) == expected 85 | end 86 | 87 | test "raises for not existing atoms when safe = true and ignore = false" do 88 | assert_raise ArgumentError, fn -> 89 | input = %{"a" => 2, "b" => %{"c" => 4}, "__not___existing__" => 5} 90 | AtomicMap.convert(input, safe: true, ignore: false) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AtomicMap 2 | 3 | [![Build status](https://travis-ci.org/ruby2elixir/atomic_map.svg "Build status")](https://travis-ci.org/ruby2elixir/atomic_map) 4 | [![Hex version](https://img.shields.io/hexpm/v/atomic_map.svg "Hex version")](https://hex.pm/packages/atomic_map) 5 | ![Hex downloads](https://img.shields.io/hexpm/dt/atomic_map.svg "Hex downloads") 6 | 7 | 8 | A small utility to convert Elixir maps with mixed string/atom keys to atom-only keyed maps. Optionally with a safe option, to prevent [atom space exhaustion of the Erlang VM](https://erlangcentral.org/wiki/index.php?title=String_Conversion_To_Atom). Since v0.8 it also supports conversion of keys from `CamelCase` to `under_score` format. 9 | 10 | AtomicMap can convert simple maps or nested structures such as lists of maps; see [Nested Structures](#nested-structures) below for examples. 11 | 12 | ## Usage 13 | 14 | ### Safety 15 | 16 | With no options, it safely converts string keys in maps to atoms, using [`String.to_existing_atom/1`](https://hexdocs.pm/elixir/String.html#to_existing_atom/1). 17 | 18 | ```elixir 19 | iex> AtomicMap.convert(%{"a" => "a", "b" => "b", c: "c"}) 20 | %{a: "a", b: "b", c: "c"} 21 | ``` 22 | 23 | Because safe conversion uses [`String.to_existing_atom/1`](https://hexdocs.pm/elixir/String.html#to_existing_atom/1), it will raise when the target atom does not exist. 24 | 25 | ```elixir 26 | iex> AtomicMap.convert(%{"abcdefg" => "a", "b" => "b"}) 27 | ** (ArgumentError) argument error 28 | :erlang.binary_to_existing_atom("abcdefg", :utf8) 29 | ``` 30 | 31 | To have safe conversion can ignore unsafe keys, leaving them as strings, pass `true` for the `ignore` option. 32 | 33 | ```elixir 34 | iex> AtomicMap.convert(%{"abcdefg" => "a", "b" => "b"}, %{ignore: true}) 35 | %{:b => "b", "abcdefg" => "a"} 36 | ``` 37 | 38 | To disable safe conversion and allow new atoms to be created, pass `false` for the `safe:` option. 39 | (This makes the `ignore` option irrelevant.) 40 | If the input is user-generated, converting only expected keys will prevent excessive atom creation. 41 | 42 | ```elixir 43 | iex> map = %{"expected_key" => "a", "b" => "b", "unexpected_key" => "c"} 44 | %{"expected_key" => "a", "b" => "b", "unexpected_key" => "c"} 45 | 46 | iex> filtered_map = Map.take(map, ["expected_key", "b"]) 47 | %{"b" => "b", "expected_key" => "a"} 48 | 49 | iex> AtomicMap.convert(filtered_map, %{safe: false}) 50 | %{b: "b", expected_key: "a"} 51 | ``` 52 | 53 | ### Underscoring 54 | 55 | By default, `"CamelCase"` string keys will be converted to `under_score` atom keys. 56 | 57 | ``` 58 | iex> AtomicMap.convert(%{ "CamelCase" => "hi" }) 59 | ** (ArgumentError) argument error 60 | :erlang.binary_to_existing_atom("camel_case", :utf8) 61 | ``` 62 | 63 | Note that `"camel_case"` was the string that failed conversion. 64 | If that atom is explicitly created first, the conversion will succeed. 65 | 66 | ```elixir 67 | iex> :camel_case 68 | :camel_case 69 | iex> AtomicMap.convert(%{ "CamelCase" => "hi" }) 70 | %{camel_case: "hi"} 71 | ``` 72 | 73 | Allowing unsafe conversions will also work. 74 | If the input is user-generated, converting only expected keys will prevent excessive atom creation. 75 | 76 | ```elixir 77 | iex> map = %{"CamelCase" => "a", "b" => "b", "AnotherCamelCase" => "c"} 78 | %{"CamelCase" => "a", "b" => "b", "AnotherCamelCase" => "c"} 79 | 80 | iex> filtered_map = Map.take(map, ["CamelCase", "b"]) 81 | %{"b" => "b", "CamelCase" => "a"} 82 | 83 | iex> AtomicMap.convert(filtered_map, %{safe: false}) 84 | %{b: "b", camel_case: "a"} 85 | ``` 86 | 87 | ### Replacing Hyphens 88 | 89 | `"hyphenated-string"` keys will always be converted to `under_score` atom keys. 90 | There is currently no way to disable this behavior. 91 | 92 | ```elixir 93 | iex> AtomicMap.convert(%{"some-key" => "a", "b" => "c"}) 94 | ** (ArgumentError) argument error 95 | :erlang.binary_to_existing_atom("some_key", :utf8) 96 | ``` 97 | 98 | Note that `"some_key"` was the string that failed conversion. 99 | If that atom is explicitly created first, the conversion will succeed. 100 | 101 | ```elixir 102 | iex> :some_key 103 | :some_key 104 | 105 | iex> AtomicMap.convert(%{"some-key" => "a", "b" => "c"}) 106 | %{b: "c", some_key: "a"} 107 | ``` 108 | 109 | Allowing unsafe conversions will also work. 110 | If the input is user-generated, converting only expected keys will prevent excessive atom creation. 111 | 112 | ```elixir 113 | iex> map = %{"some-key" => "a", "b" => "b", "another-key" => "c"} 114 | %{"some-key" => "a", "b" => "b", "another-key" => "c"} 115 | 116 | iex> filtered_map = Map.take(map, ["some-key", "b"]) 117 | %{"b" => "b", "some-key" => "a"} 118 | 119 | iex> AtomicMap.convert(filtered_map, %{safe: false}) 120 | %{b: "b", some_key: "a"} 121 | ``` 122 | 123 | ### Nested Structures 124 | 125 | ```elixir 126 | # works with nested maps 127 | iex> AtomicMap.convert(%{"a" => 2, "b" => %{"c" => 4}}) 128 | %{a: 2, b: %{c: 4}} 129 | 130 | # works with nested maps + lists + mixed key types (atoms + binaries) 131 | iex> AtomicMap.convert([ %{"c" => 1}, %{:c => 2}, %{"c" => %{:b => 4}}], %{safe: true}) 132 | [%{c: 1}, %{c: 2}, %{c: %{b: 4}}] 133 | ``` 134 | 135 | ## Installation 136 | 1. Add atomic_map to your list of dependencies in `mix.exs`: 137 | 138 | def deps do 139 | [{:atomic_map, "~> 0.8"}] 140 | end 141 | 142 | ## Todo: 143 | - maybe allow direct conversion to a struct, like Poison does it: as: %SomeStruct{}... 144 | 145 | 146 | ## Benchmark 147 | 148 | $ mix bench 149 | --------------------------------------------------------------------------------