diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index c391be1..39b5598 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -6,20 +6,23 @@ on: jobs: test: - runs-on: ubuntu-latest + name: Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }} + runs-on: ubuntu-24.04 strategy: + fail-fast: false matrix: - otp: ['23.2', '24.2', '25.0'] - elixir: ['1.12.3', '1.13.3', '1.14.0'] - exclude: - - otp: '25.0' - elixir: '1.12.3' + include: + - otp: '27' + elixir: '1.17.3' + - otp: '27' + elixir: '1.18.4' + - otp: '28' + elixir: '1.19.5' steps: - - uses: actions/checkout@v2 - - uses: erlef/setup-elixir@v1 + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} - run: mix deps.get - run: mix test - diff --git a/lib/params.ex b/lib/params.ex index 3012c7a..382632c 100644 --- a/lib/params.ex +++ b/lib/params.ex @@ -10,7 +10,7 @@ defmodule Strukt.Params do when is_map(params) and map_size(params) == 0, do: params - def transform(module, %{__struct__: _} = params, nil = _struct) do + def transform(module, params, nil = _struct) when is_struct(params) do transform_from_struct(module, params, params) end @@ -22,7 +22,7 @@ defmodule Strukt.Params do transform_from_struct(module, params, struct) end - def transform(module, params, %{__struct__: _} = struct) do + def transform(module, params, struct) when is_struct(struct) do transform_from_struct(module, params, struct) end @@ -30,13 +30,13 @@ defmodule Strukt.Params do transform(module, params, struct) end - defp transform(module, params, nil = struct, cardinality: :many) do + defp transform(module, params, nil = struct, cardinality: :many) when is_list(params) do Enum.map(params, fn param -> transform(module, param, struct) end) end - defp transform(module, params, struct, cardinality: :many) do + defp transform(module, params, struct, cardinality: :many) when is_list(params) do params |> Enum.with_index() |> Enum.map(fn {param, index} -> @@ -44,10 +44,12 @@ defmodule Strukt.Params do end) end + # for delay the type error to casting + defp transform(_module, params, _struct, cardinality: :many), do: params + defp transform_from_struct(module, params, struct) do struct |> Map.from_struct() - |> Map.to_list() |> Enum.map(fn {key, _value} -> case module.__schema__(:field_source, key) do nil -> @@ -63,11 +65,14 @@ defmodule Strukt.Params do defp map_value_to_field(module, field, value, struct) do case module.__schema__(:type, field) do - {:parameterized, Ecto.Embedded, - %Ecto.Embedded{ - cardinality: cardinality, - related: embedded_module - }} -> + # since ecto 3.12.0, parameterized type represent as a tuple instead of a triple + # see https://github.com/elixir-ecto/ecto/commit/21c6068 + {:parameterized, + {Ecto.Embedded, + %Ecto.Embedded{ + cardinality: cardinality, + related: embedded_module + }}} -> {field, transform(embedded_module, value, get_struct_field_value(struct, field), cardinality: cardinality diff --git a/lib/strukt.ex b/lib/strukt.ex index 5d92d04..f2ee863 100644 --- a/lib/strukt.ex +++ b/lib/strukt.ex @@ -496,7 +496,7 @@ defmodule Strukt do embeds: @cast_embed_fields }) - Module.eval_quoted(__ENV__, typespec_ast) + Code.eval_quoted(typespec_ast, [], __ENV__) defp validator_builder_call(unquote(changeset), opts), do: unquote(validate_body) @@ -565,7 +565,7 @@ defmodule Strukt do # When we have a list of entities, we are overwriting the embeds with a new set Ecto.Changeset.put_embed(changeset, field, Enum.map(entities, &Map.from_struct/1)) - other when is_map(other) or is_list(other) -> + other -> # For all other parameters, we need to cast. Depending on how the embedded entity is configured, this may raise an error cast_embed(changeset, field) end @@ -599,7 +599,7 @@ defmodule Strukt do # Generate the __validate__ function validate_ast = Strukt.Validation.generate(__MODULE__, @validated_fields) - Module.eval_quoted(__ENV__, validate_ast) + Code.eval_quoted(validate_ast, [], __ENV__) # Handle conditional implementation of Jason.Encoder if Module.get_attribute(__MODULE__, :derives_jason) do diff --git a/lib/typespec.ex b/lib/typespec.ex index 240b2ac..15909a7 100644 --- a/lib/typespec.ex +++ b/lib/typespec.ex @@ -138,30 +138,30 @@ defmodule Strukt.Typespec do defp type_to_type_name(:date), do: compose_call(Date, :t, []) - defp type_to_type_name({:__aliases__, _, parts} = ast) do - case Module.concat(parts) do + defp type_to_type_name(mod) when is_atom(mod) and not is_nil(mod) do + case mod do Ecto.Enum -> primitive(:atom) Ecto.UUID -> - primitive(:string) + compose_call(Ecto.UUID, :t, []) mod -> - with {:module, _} <- Code.ensure_compiled(mod) do - try do - if Kernel.Typespec.defines_type?(mod, {:t, 0}) do - compose_call(ast, :t, []) - else - # No t/0 type defined, so fallback to any/0 - primitive(:any) - end - rescue - ArgumentError -> - # We shouldn't hit this branch, but if Elixir can't find module metadata - # during defines_type?, it raises ArgumentError, so we handle this like the - # other pessimistic cases - primitive(:any) - end + with {:module, _} <- Code.ensure_compiled(mod), + {:ok, mod_types} <- Code.Typespec.fetch_types(mod), + t0 when not is_nil(t0) <- + Enum.find( + mod_types, + &match?( + {kind, {:t, _, args}} when kind in [:type, :opaque] and length(args) == 0, + &1 + ) + ) do + compose_call( + {:__aliases__, [alias: false], Enum.map(Module.split(mod), &String.to_atom/1)}, + :t, + [] + ) else _ -> # Module is unable to be loaded, either due to compiler deadlock, or because diff --git a/lib/validation.ex b/lib/validation.ex index 0ae0281..1c8f6b3 100644 --- a/lib/validation.ex +++ b/lib/validation.ex @@ -78,7 +78,7 @@ defmodule Strukt.Validation do :format -> cond do - Regex.regex?(data_or_opts) -> + match?(%Regex{}, data_or_opts) -> quote do Ecto.Changeset.validate_format( unquote(field), diff --git a/mix.exs b/mix.exs index 27dfbf5..5cead50 100644 --- a/mix.exs +++ b/mix.exs @@ -5,16 +5,12 @@ defmodule Strukt.MixProject do [ app: :strukt, version: "0.3.2", - elixir: "~> 1.11", + elixir: "~> 1.14", description: description(), package: package(), start_permanent: Mix.env() == :prod, deps: deps(), elixirc_paths: elixirc_paths(Mix.env()), - preferred_cli_env: [ - docs: :docs, - "hex.publish": :docs - ], name: "Strukt", source_url: "https://github.com/bitwalker/strukt", homepage_url: "http://github.com/bitwalker/strukt", @@ -40,15 +36,23 @@ defmodule Strukt.MixProject do ] end + def cli do + [ + preferred_envs: [ + docs: :docs, + "hex.publish": :docs + ] + ] + end + defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ecto, "~> 3.0"}, + {:ecto, "~> 3.12"}, {:jason, "> 0.0.0", optional: true}, - {:uniq, "~> 0.1", only: [:test]}, {:ex_doc, "> 0.0.0", only: [:docs], runtime: false} ] end diff --git a/mix.lock b/mix.lock index b147517..0f7839d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,12 @@ %{ - "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, - "ecto": {:hex, :ecto, "3.9.1", "67173b1687afeb68ce805ee7420b4261649d5e2deed8fe5550df23bab0bc4396", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c80bb3d736648df790f7f92f81b36c922d9dd3203ca65be4ff01d067f54eb304"}, + "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, - "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, - "uniq": {:hex, :uniq, "0.5.3", "d9d54dac49fa4bb4b7fff0dd050281a8ded507813396de0507196347ecf53be1", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "6961e04cb62227f9f5fae3d8e26eede990826b885ba6e2f33973222cc856f2fb"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, } diff --git a/test/strukt_test.exs b/test/strukt_test.exs index a624f94..aa286a4 100644 --- a/test/strukt_test.exs +++ b/test/strukt_test.exs @@ -3,7 +3,7 @@ defmodule Strukt.Test do doctest Strukt - alias Uniq.UUID + alias Ecto.UUID # See test/support/defstruct_fixtures.ex alias Strukt.Test.Fixtures @@ -19,18 +19,17 @@ defmodule Strukt.Test do test "default primary key is autogenerated by new/1" do assert {:ok, %Fixtures.Simple{uuid: uuid}} = Fixtures.Simple.new() - assert {:ok, info} = UUID.info(uuid) - assert 4 == info.version + assert {:ok, _} = UUID.cast(uuid) end test "can cast primary key from params" do - uuid = UUID.uuid4() + uuid = UUID.generate() assert {:ok, %Fixtures.Simple{uuid: ^uuid}} = Fixtures.Simple.new(uuid: uuid) end test "can define a struct and its containing module inline using defstruct/2" do - uuid = UUID.uuid4() + uuid = UUID.generate() assert {:ok, %Fixtures.Inline{uuid: ^uuid, name: ""}} = Fixtures.Inline.new(uuid: uuid) assert true = Fixtures.Inline.test() @@ -295,6 +294,19 @@ defmodule Strukt.Test do refute is_nil(uuid) end + test "return error when passing wrong typed value to embeds_many field" do + {:error, + %Ecto.Changeset{ + action: :insert, + changes: %{}, + errors: [items: {"is invalid", [validation: :embed, type: {:array, :map}]}], + valid?: false + }} = + Fixtures.CustomFieldsWithEmbeddedSchema.new(%{ + items: "iterms" + }) + end + test "parse custom fields with boolean value" do assert {:ok, %Strukt.Test.Fixtures.CustomFieldsWithBoolean{enabled: false, uuid: uuid}} = Fixtures.CustomFieldsWithBoolean.new(%{Enabled: false}) @@ -313,7 +325,7 @@ defmodule Strukt.Test do test "can control struct generation with outer attributes" do assert {:ok, %Fixtures.OuterAttrs{} = obj} = Fixtures.OuterAttrs.new() - assert {:ok, _} = UUID.info(obj.uuid) + assert {:ok, _} = UUID.cast(obj.uuid) assert %DateTime{} = obj.inserted_at assert {:ok, json} = Jason.encode(obj) @@ -545,11 +557,31 @@ defmodule Strukt.Test do Fixtures.EmbedsOneTypeSpec.expected_type_spec_ast_str() end + test "custom ecto type" do + require Fixtures.CustomEctoTypeTypeSepc + + assert inspect( + Strukt.Typespec.generate(%Strukt.Typespec{ + caller: Strukt.Test.Fixtures.CustomEctoTypeTypeSepc, + fields: [:uri], + info: %{ + uri: %{ + type: :field, + value_type: Custom.EctoType, + required: true + } + }, + embeds: [] + }) + ) == + Fixtures.CustomEctoTypeTypeSepc.expected_type_spec_ast_str() + end + defp changeset_errors(%Ecto.Changeset{} = cs) do cs |> Ecto.Changeset.traverse_errors(fn {msg, opts} -> Enum.reduce(opts, msg, fn - {key, {:parameterized, Ecto.Enum, %{mappings: values}}}, acc -> + {key, {:parameterized, {Ecto.Enum, %{mappings: values}}}}, acc -> String.replace(acc, "%{#{key}}", values |> Keyword.values() |> Enum.join(", ")) {key, %Range{} = value}, acc -> diff --git a/test/support/defstruct_fixtures.ex b/test/support/defstruct_fixtures.ex index 66cb8f2..6565641 100644 --- a/test/support/defstruct_fixtures.ex +++ b/test/support/defstruct_fixtures.ex @@ -9,6 +9,31 @@ defmodule Aux do end end +defmodule Custom.EctoType do + @moduledoc "Custom Ecto Type for test" + @type t() :: URI.t() + + use Ecto.Type + def type, do: :map + + def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)} + def cast(%URI{} = uri), do: {:ok, uri} + + def cast(_), do: :error + + def load(data) when is_map(data) do + data = + for {key, val} <- data do + {String.to_existing_atom(key), val} + end + + {:ok, struct!(URI, data)} + end + + def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)} + def dump(_), do: :error +end + defmodule Strukt.Test.Fixtures do use Strukt @@ -72,6 +97,25 @@ defmodule Strukt.Test.Fixtures do end end + defmodule CustomEctoTypeTypeSepc do + use Strukt + + @primary_key false + defstruct do + field(:uri, Custom.EctoType) + field(:foobar, :string) + end + + defmacro expected_type_spec_ast_str do + quote context: __MODULE__ do + @type t :: %__MODULE__{ + uri: Custom.EctoType.t() + } + end + |> inspect() + end + end + defmodule CustomFields do @moduledoc "This module represents the params keys are not snake case" @@ -327,7 +371,9 @@ defmodule Strukt.Test.Fixtures do defstruct ValidateSets do @moduledoc "This module exercises validations based on set membership" - field(:one_of, :string, one_of: [values: ["a", "b", "c"], message: "must be one of [a, b, c]"]) + field(:one_of, :string, + one_of: [values: ["a", "b", "c"], message: "must be one of [a, b, c]"] + ) field(:none_of, :string, none_of: [values: ["a", "b", "c"], message: "cannot be one of [a, b, c]"]