diff --git a/README.md b/README.md index a66cbbf..ecffd53 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,12 @@ unless defaulted or provided as attributes to the constructor. } ``` +## String keyed attributes +The attributes map provided to the constructor functions can be either string-keyed or atom-key, not mixed. + +Under the covers we are using [Ecto.Changeset.cast/4](https://hexdocs.pm/ecto/Ecto.Changeset.html#cast/4), to +copy and cast attributes into the struct being created. + ## Installation Because this plugin supports the interface defined by the `TypedStruct` macro, installation assumes you've already added that dependency. diff --git a/lib/typed_struct_ctor.ex b/lib/typed_struct_ctor.ex index ef7e4c9..767a179 100644 --- a/lib/typed_struct_ctor.ex +++ b/lib/typed_struct_ctor.ex @@ -196,13 +196,20 @@ defmodule TypedStructCtor do @doc false def do_from(mod, base_struct, attrs) when is_struct(base_struct) do - attrs = - base_struct - |> Map.from_struct() - |> Map.drop(mod.__not_mapped__()) - |> Map.merge(attrs) - - TypedStructCtor.do_new(mod, attrs) + # `attrs` could be string-keyed map, use Ecto to convert to validated atom-keys + case cast(mod.__struct__(), attrs, mod.__all__(), force_changes: true) do + %{valid?: true, changes: valid_attrs} -> + merged_attrs = + base_struct + |> Map.from_struct() + |> Map.drop(mod.__not_mapped__()) + |> Map.merge(valid_attrs) + + TypedStructCtor.do_new(mod, merged_attrs) + + other -> + other + end end def do_from(_mod, _base_struct, _attrs), do: {:error, :base_struct_must_be_a_struct} diff --git a/mix.exs b/mix.exs index 2e447d9..5a51b92 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule TypedStructCtor.MixProject do def project do [ app: :typed_struct_ctor, - version: "0.1.1", + version: "0.1.2", elixir: "~> 1.15", start_permanent: Mix.env() == :prod, deps: deps(), @@ -35,14 +35,14 @@ defmodule TypedStructCtor.MixProject do defp package() do [ licenses: ["MIT"], - links: %{"Github" => "https://github.com/withbelay/typed_struct_ctor"} + links: %{"Github" => "https://github.com/leggebroten/typed_struct_ctor"} ] end defp deps do [ {:credo, "~> 1.7", only: [:dev, :test]}, - {:dialyxir, "~> 1.3", only: [:dev, :test]}, + {:dialyxir, "~> 1.3", only: [:dev, :test], runtime: false}, {:ecto, "~> 3.10"}, {:ex_doc, "~> 0.30", only: :dev}, {:typedstruct, "~> 0.5.2"}, diff --git a/test/typed_struct_ctor_test.exs b/test/typed_struct_ctor_test.exs index 77db513..c70cf9b 100644 --- a/test/typed_struct_ctor_test.exs +++ b/test/typed_struct_ctor_test.exs @@ -50,6 +50,7 @@ defmodule Mappable do plugin(TypedStructEctoChangeset) plugin(TypedStructCtor) + field(:defaulted, :string, default: "defaulted") field(:mapped_by_default, :string, default: "bar") field(:id, :string, mappable?: false, default_apply: {Ecto.UUID, :generate, []}) end @@ -75,6 +76,22 @@ defmodule TypedStructCtorTest do }) end + test "when attributes are string keys, map to atoms" do + assert {:ok, + %AStruct{ + not_required_defaulted: 1, + not_required_not_defaulted: 2, + required_defaulted: 3, + required_not_defaulted: 4 + }} == + AStruct.new(%{ + "not_required_defaulted" => "1", + "not_required_not_defaulted" => "2", + "required_defaulted" => "3", + "required_not_defaulted" => "4" + }) + end + test "when no attributes supplied, use defaults" do assert {:error, message} = AStruct.new(%{}) @@ -192,6 +209,19 @@ defmodule TypedStructCtorTest do assert mapped_struct.id == "baz" end + test "when attributes have string keys, they're cast correctly as atom keys" do + original_struct = Mappable.new!(%{"mapped_by_default" => "foo"}) + + {:ok, %{id: "baz", mapped_by_default: "bar"}} = + Mappable.from(original_struct, %{"id" => "baz", "mapped_by_default" => "bar"}) + + {:ok, %{id: "baz", mapped_by_default: "foo"}} = Mappable.from(original_struct, %{"id" => "baz"}) + + {:ok, %{id: id, mapped_by_default: "foo"}} = Mappable.from(original_struct) + refute id == original_struct.id + {:ok, _uuid} = Ecto.UUID.cast(id) + end + test "when fails validation, return error tuple" do original_struct = Mappable.new!(%{mapped_by_default: "foo"}) {:error, changeset} = Mappable.from(original_struct, %{mapped_by_default: nil})