diff --git a/lib/ecto_command.ex b/lib/ecto_command.ex index a95b6c2..2c2a928 100644 --- a/lib/ecto_command.ex +++ b/lib/ecto_command.ex @@ -54,6 +54,11 @@ defmodule EctoCommand do :subset ] + @valid_embed_validators [ + :required, + :change + ] + @doc false defmacro __using__(_) do quote do @@ -67,6 +72,7 @@ defmodule EctoCommand do internal: 2, internal: 3, embeds_one: 3, + embeds_one: 4, cast_embedded_fields: 2 ] @@ -259,7 +265,7 @@ defmodule EctoCommand do __MODULE__, unquote(name), unquote(type), - opts |> Keyword.drop(unquote(@command_options ++ @valid_validators)) + Keyword.drop(opts, unquote(@command_options ++ @valid_validators)) ) end end @@ -314,6 +320,20 @@ defmodule EctoCommand do end end + defmacro embeds_one(name, schema, opts, do: block) when is_list(opts) do + quote do + defmodule unquote(schema) do + use EctoCommand + + command do + unquote(block) + end + end + + unquote(embed_submodule(name, schema, opts)) + end + end + defmacro embeds_one(name, schema, do: block) do quote do defmodule unquote(schema) do @@ -324,10 +344,14 @@ defmodule EctoCommand do end end - Ecto.Schema.embeds_one(unquote(name), unquote(schema)) + unquote(embed_submodule(name, schema, [])) end end + defmacro embeds_one(name, schema, opts) when is_list(opts) do + embed_submodule(name, schema, opts) + end + @doc false @spec trim_fields(map(), [atom()]) :: map() def trim_fields(params, trim_fields) do @@ -366,4 +390,30 @@ defmodule EctoCommand do |> Pipeline.halt() end end + + def embed_submodule(name, schema, opts) do + quote do + opts = unquote(opts) + + Module.put_attribute(__MODULE__, :command_fields, {unquote(name), unquote(schema), opts}) + + Enum.each(unquote(@valid_embed_validators), fn validator -> + if opts[validator] !== nil && opts[validator] !== false do + parsed_opts = if opts[validator] == true, do: [], else: opts[validator] + + Module.put_attribute( + __MODULE__, + :validators, + {unquote(name), validator, Macro.escape(parsed_opts)} + ) + end + end) + + Ecto.Schema.embeds_one( + unquote(name), + unquote(schema), + Keyword.drop(opts, unquote(@command_options ++ @valid_embed_validators)) + ) + end + end end diff --git a/lib/open_api/open_api.ex b/lib/open_api/open_api.ex index ad11174..b269709 100644 --- a/lib/open_api/open_api.ex +++ b/lib/open_api/open_api.ex @@ -129,7 +129,13 @@ defmodule EctoCommand.OpenApi do %{type: :array, items: schema_for(inner_type, Keyword.drop(opts, [:doc, :default]))} end - defp base_schema(type, _opts), do: base_schema(type) + defp base_schema(type, _opts) do + if is_atom(type) and function_exported?(type, :schema, 0) do + Map.from_struct(type.schema()) + else + base_schema(type) + end + end defp base_schema(:id), do: %{type: :integer} defp base_schema(type) when type in [:float, :decimal], do: %{type: :number} diff --git a/test/unit/command/creation_test.exs b/test/unit/command/creation_test.exs index e40ae2d..7a0739e 100644 --- a/test/unit/command/creation_test.exs +++ b/test/unit/command/creation_test.exs @@ -59,6 +59,41 @@ defmodule Unit.EctoCommand.CreationTest do })} == module_name.new(%{address: %{street: "piazzale loreto", city: "it/milano", zip: "20142"}}) end + test "a param could have subparams - they accept options" do + module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") + + define_a_module_with_fields module_name do + embeds_one :address, Address, required: true do + param :street, :string + param :city, :string + param :zip, :string + end + + embeds_one :phone, Phone, required: false do + param :number, :string + param :country_code, :string + param :prefix, :string + end + end + + address_module = String.to_atom("Elixir.#{module_name}.Address") + + assert {:ok, + struct!(module_name, %{ + address: + struct!(address_module, %{ + street: "piazzale loreto", + city: "it/milano", + zip: "20142" + }), + phone: nil + })} == + module_name.new(%{ + address: %{street: "piazzale loreto", city: "it/milano", zip: "20142"}, + phone: nil + }) + end + test "when a subparam is invalid an invalid changeset is returned" do module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") @@ -96,5 +131,43 @@ defmodule Unit.EctoCommand.CreationTest do assert %{address: %{city: ["can't be blank"], street: ["should be at least 10 character(s)"]}} == errors_on(changeset) end + + test "an already existing module could be embedded - it accept options" do + embedded_module = String.to_atom("Sample#{:rand.uniform(999_999)}") + + define_a_module_with_fields embedded_module do + param :street, :string, required: true, length: [min: 10] + param :city, :string, required: true + param :zip, :string + end + + module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") + + define_a_module_with_fields module_name do + embeds_one :address, embedded_module, required: true + embeds_one :address_2, embedded_module, required: false + + embeds_one :address_3, embedded_module, + required: false, + change: &unquote(__MODULE__).always_invalid/2 + end + + assert {:error, changeset} = + module_name.new(%{ + address: nil, + address_2: %{street: "foo", city: "New York"}, + address_3: %{street: "Madison avenue", city: "New York"} + }) + + assert %{ + address: ["can't be blank"], + address_2: %{street: ["should be at least 10 character(s)"]}, + address_3: ["is invalid"] + } == errors_on(changeset) + end + end + + def always_invalid(field, _value) do + [{field, "is invalid"}] end end diff --git a/test/unit/command/open_api/open_api_test.exs b/test/unit/command/open_api/open_api_test.exs index 20df552..926c019 100644 --- a/test/unit/command/open_api/open_api_test.exs +++ b/test/unit/command/open_api/open_api_test.exs @@ -4,6 +4,18 @@ defmodule Unit.EctoCommand.OpenApi.OpenApiTest do use ExUnit.Case, async: true use EctoCommand.Test.CommandCase + defmodule EmbeddedSample do + @moduledoc false + + use EctoCommand + use EctoCommand.OpenApi, title: "EmbeddedSample" + + command do + param :id, :string, doc: Type.uuid() + param :title, :string, required: true, length: [min: 5], doc: [example: "A title"] + end + end + defmodule Sample do @moduledoc false @@ -43,6 +55,8 @@ defmodule Unit.EctoCommand.OpenApi.OpenApiTest do param :a_map, :map, doc: [description: "A map"], default: %{} param :a_map_with_int_values, {:map, :integer}, doc: [description: "A map with integer values"], default: %{a: 1} + embeds_one :embedded_sample, EmbeddedSample, doc: [description: "A embedded sample"] + internal :triggered_by, :map internal :uploaded_by, :string end @@ -156,6 +170,22 @@ defmodule Unit.EctoCommand.OpenApi.OpenApiTest do description: "A map with integer values", default: %{a: 1}, example: %{a: 1} + }, + embedded_sample: %OpenApiSpex.Schema{ + title: "EmbeddedSample", + required: [:title], + type: :object, + description: "A embedded sample", + properties: %{ + id: %OpenApiSpex.Schema{ + type: :string, + description: "UUID", + format: :uuid, + example: "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6" + }, + title: %OpenApiSpex.Schema{minLength: 5, type: :string, example: "A title"} + }, + example: %{id: "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6", title: "A title"} } } == Sample.schema().properties @@ -188,7 +218,8 @@ defmodule Unit.EctoCommand.OpenApi.OpenApiTest do name: "Mario", phone: "(425) 123-4567", type_id: "", - uploaded_at: "2020-04-20T16:20:00Z" + uploaded_at: "2020-04-20T16:20:00Z", + embedded_sample: %{id: "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6", title: "A title"} } == Sample.schema().example refute Map.has_key?(Sample.schema().example, :hidden_field)