From cc9318589bd8444a1597b6283478dcafcc61f0e0 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Fri, 27 Sep 2024 09:16:40 +0800 Subject: [PATCH] feat: support `:defrecord` and `:defrecordp` and custom definer (#21) --- README.md | 15 ++ guides/plugins/reflection.md | 6 +- lib/typed_structor.ex | 105 +++++++++---- lib/typed_structor/definer/defexception.ex | 13 +- lib/typed_structor/definer/defrecord.ex | 146 ++++++++++++++++++ lib/typed_structor/definer/defrecordp.ex | 21 +++ lib/typed_structor/definer/defstruct.ex | 58 ++----- lib/typed_structor/definer/utils.ex | 72 +++++++++ lib/typed_structor/definition.ex | 1 + mix.exs | 3 + test/definer_test.exs | 139 +++++++++++++++++ test/guides/plugins/reflection_test.exs | 2 +- .../definer/custom_definer_test.exs | 75 +++++++++ test/typed_structor/definer/doc_test.exs | 13 ++ test/typed_structor/definer/utils_test.exs | 134 ++++++++++++++++ 15 files changed, 721 insertions(+), 82 deletions(-) create mode 100644 lib/typed_structor/definer/defrecord.ex create mode 100644 lib/typed_structor/definer/defrecordp.ex create mode 100644 lib/typed_structor/definer/utils.ex create mode 100644 test/typed_structor/definer/custom_definer_test.exs create mode 100644 test/typed_structor/definer/doc_test.exs create mode 100644 test/typed_structor/definer/utils_test.exs diff --git a/README.md b/README.md index d0386c7..e6dc0ca 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,21 @@ defmodule HTTPException do end ``` +## Define records related macros + +In Elixir, you can use the Record module to define and work with Erlang records, +making interoperability between Elixir and Erlang more seamless. + +```elixir +defmodule TypedStructor.User do + use TypedStructor + + typed_structor definer: :defrecord, record_name: :user, record_tag: User, enforce: true do + field :name, String.t() + field :age, pos_integer() + end +end +``` ## Documentation To add a `@typedoc` to the struct type, just add the attribute in the typed_structor block: diff --git a/guides/plugins/reflection.md b/guides/plugins/reflection.md index 6f72f90..dd91372 100644 --- a/guides/plugins/reflection.md +++ b/guides/plugins/reflection.md @@ -16,7 +16,11 @@ defmodule Guides.Plugins.Reflection do enforced_fields = definition.fields - |> Stream.filter(&Keyword.get(&1, :enforce, false)) + |> Stream.filter(fn field -> + Keyword.get_lazy(field, :enforce, fn -> + Keyword.get(definition.options, :enforce, false) + end) + end) |> Stream.map(&Keyword.fetch!(&1, :name)) |> Enum.to_list() diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 7d0cb61..26abbad 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -5,6 +5,13 @@ defmodule TypedStructor do |> String.split("", parts: 2) |> Enum.fetch!(1) + @built_in_definers [ + defstruct: TypedStructor.Definer.Defstruct, + defexception: TypedStructor.Definer.Defexception, + defrecord: TypedStructor.Definer.Defrecord, + defrecordp: TypedStructor.Definer.Defrecordp + ] + defmacro __using__(_opts) do quote do import TypedStructor, only: [typed_structor: 1, typed_structor: 2] @@ -28,24 +35,28 @@ defmodule TypedStructor do The available definers are: - `:defstruct`, which defines a struct and a type for a given definition - `:defexception`, which defines an exception and a type for a given definition + - `:defrecord`, which defines record macros and a type for a given definition + - `:defrecordp`, which defines private record macros and a type for a given definition ### `:defstruct` options - * `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`. + #{TypedStructor.Definer.Defstruct.__additional_options__()} ### `:defexception` options - * `:define_struct` - if `false`, the type will be defined, but the exception struct will not be defined. Defaults to `true`. + #{TypedStructor.Definer.Defexception.__additional_options__()} + + ### `:defrecord` and `:defrecordp` options + + #{TypedStructor.Definer.Defrecord.__additional_options__()} ### custom definer defmodule MyStruct do - # you must require the definer module to use its define/1 macro - require MyDefiner - use TypedStructor - typed_structor definer: &MyDefiner.define/1 do + typed_structor definer: MyDefiner do + field :name, String.t() field :age, integer() end @@ -91,7 +102,7 @@ defmodule TypedStructor do defmacro typed_structor(options \\ [], do: block) when is_list(options) do case Keyword.pop(options, :module) do {nil, options} -> - __typed_structor__(__CALLER__.module, options, block) + __typed_structor__(__CALLER__, options, block) {module, options} -> quote do @@ -106,16 +117,18 @@ defmodule TypedStructor do end end - defp __typed_structor__(mod, options, block) do - Module.register_attribute(mod, :__ts_options__, accumulate: false) - Module.register_attribute(mod, :__ts_struct_fields__, accumulate: true) - Module.register_attribute(mod, :__ts_struct_parameters__, accumulate: true) - Module.register_attribute(mod, :__ts_struct_plugins__, accumulate: true) - Module.register_attribute(mod, :__ts_definition____, accumulate: false) + defp __typed_structor__(caller, options, block) when is_list(options) do + case fetch_definer!(caller, options) do + :error -> :ok + {:ok, definer} -> Module.put_attribute(caller.module, :__ts_definer__, definer) + end - quote do - @__ts_options__ unquote(options) + Module.register_attribute(caller.module, :__ts_struct_fields__, accumulate: true) + Module.register_attribute(caller.module, :__ts_struct_parameters__, accumulate: true) + Module.register_attribute(caller.module, :__ts_struct_plugins__, accumulate: true) + Module.register_attribute(caller.module, :__ts_definition____, accumulate: false) + quote do # create a lexical scope try do import TypedStructor, @@ -132,7 +145,7 @@ defmodule TypedStructor do try do definition = TypedStructor.__call_plugins_before_definitions__(%TypedStructor.Definition{ - options: @__ts_options__, + options: unquote(options), fields: Enum.reverse(@__ts_struct_fields__), parameters: Enum.reverse(@__ts_struct_parameters__) }) @@ -140,7 +153,6 @@ defmodule TypedStructor do @__ts_definition__ definition after # cleanup - Module.delete_attribute(__MODULE__, :__ts_options__) Module.delete_attribute(__MODULE__, :__ts_struct_fields__) Module.delete_attribute(__MODULE__, :__ts_struct_parameters__) end @@ -154,10 +166,30 @@ defmodule TypedStructor do # cleanup Module.delete_attribute(__MODULE__, :__ts_struct_plugins__) Module.delete_attribute(__MODULE__, :__ts_definition__) + Module.delete_attribute(__MODULE__, :__ts_definer__) end end end + defp fetch_definer!(caller, options) when is_list(options) do + case Keyword.fetch(options, :definer) do + :error -> + :error + + {:ok, definer} -> + case Macro.expand(definer, caller) do + built_in_or_mod when is_atom(built_in_or_mod) -> + {:ok, built_in_or_mod} + + other -> + raise ArgumentError, """ + Definer must be one of :defstruct, :defexception, :defrecord, :defrecordp or a module that defines a `define/1` macro, + got: #{inspect(other)} + """ + end + end + end + # register global plugins defp register_global_plugins do :typed_structor @@ -219,7 +251,7 @@ defmodule TypedStructor do options = Keyword.merge(options, name: name, type: Macro.escape(type)) quote do - @__ts_struct_fields__ Keyword.merge(@__ts_options__, unquote(options)) + @__ts_struct_fields__ unquote(options) end end @@ -272,21 +304,30 @@ defmodule TypedStructor do end defmacro __define__(definition) do - quote bind_quoted: [definition: definition] do - case Keyword.get(definition.options, :definer, :defstruct) do - :defstruct -> - require TypedStructor.Definer.Defstruct - # credo:disable-for-next-line Credo.Check.Design.AliasUsage - TypedStructor.Definer.Defstruct.define(definition) - - :defexception -> - require TypedStructor.Definer.Defexception - # credo:disable-for-next-line Credo.Check.Design.AliasUsage - TypedStructor.Definer.Defexception.define(definition) - - fun when is_function(fun) -> - then(definition, fun) + definer = Module.get_attribute(__CALLER__.module, :__ts_definer__, :defstruct) + definer_mod = Keyword.get(@built_in_definers, definer, definer) + + quote do + case Keyword.fetch(unquote(definition).options, :definer) do + :error -> + :ok + + {:ok, unquote(definer)} -> + :ok + + {:ok, other} -> + IO.warn(""" + The definer option set in the `typed_structor` block is different from the definer option in the definition. + We will ignore the definer option in the definition and use the one set in the `typed_structor` block. + + Note: The definer option in the definition may be changed by a plugin. + + The effective definer is: #{inspect(unquote(definer))}, the ignored definer from the definition is: #{inspect(other)}. + """) end + + require unquote(definer_mod) + unquote(definer_mod).define(unquote(definition)) end end diff --git a/lib/typed_structor/definer/defexception.ex b/lib/typed_structor/definer/defexception.ex index 6e8afaa..815c576 100644 --- a/lib/typed_structor/definer/defexception.ex +++ b/lib/typed_structor/definer/defexception.ex @@ -1,10 +1,14 @@ defmodule TypedStructor.Definer.Defexception do + additional_options = """ + * `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`. + """ + @moduledoc """ A definer to define an exception and a type for a given definition. ## Additional options for `typed_structor` - * `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`. + #{additional_options} ## Usage @@ -18,6 +22,7 @@ defmodule TypedStructor.Definer.Defexception do """ alias TypedStructor.Definer.Defstruct + alias TypedStructor.Definer.Utils @doc """ Defines an exception and a type for a given definition. @@ -35,12 +40,14 @@ defmodule TypedStructor.Definer.Defexception do defmacro __exception_ast__(definition) do quote bind_quoted: [definition: definition] do if Keyword.get(definition.options, :define_struct, true) do - {fields, enforce_keys} = - Defstruct.__extract_fields_and_enforce_keys__(definition) + {fields, enforce_keys} = Utils.fields_and_enforce_keys(definition) @enforce_keys Enum.reverse(enforce_keys) defexception fields end end end + + @doc false + def __additional_options__, do: unquote(additional_options) end diff --git a/lib/typed_structor/definer/defrecord.ex b/lib/typed_structor/definer/defrecord.ex new file mode 100644 index 0000000..e61a528 --- /dev/null +++ b/lib/typed_structor/definer/defrecord.ex @@ -0,0 +1,146 @@ +defmodule TypedStructor.Definer.Defrecord do + additional_options = """ + * `:record_name`(**required**) - the name of the record, it must be provided. + * `:record_tag` - if set, the record will be tagged with the given value. Defaults to `nil`. + * `:define_record` - if `false`, the type will be defined, but the record will not be defined. Defaults to `true`. + """ + + @moduledoc """ + A definer to define record macors and a type for a given definition. + + ## Additional options for `typed_structor` + + #{additional_options} + + ## Usage + + defmodule MyRecord do + use TypedStructor + + typed_structor definer: :defrecord, record_name: :user, record_tag: User, define_recrod: true do + field :name, String.t(), enforce: true + field :age, pos_integer(), enforce: true + end + end + + The above code is equivalent to: + + defmodule MyRecord do + require Record + + @type t() :: {User, name :: String.t(), age :: pos_integer()} + + Record.defrecord(:user, User, [:name, :age]) + end + """ + + alias TypedStructor.Definer.Utils + + @doc """ + Defines an exception and a type for a given definition. + """ + defmacro define(definition) do + quote do + unquote(__MODULE__).__record_ast__(:public, unquote(definition)) + unquote(__MODULE__).__type_ast__(unquote(definition)) + end + end + + @doc false + defmacro __record_ast__(visibility, definition) do + quote bind_quoted: [visibility: visibility, definition: definition] do + define_record? = + Keyword.get_lazy(definition.options, :define_record, fn -> + case Keyword.fetch(definition.options, :define_struct) do + {:ok, value} -> + IO.warn(""" + Use `:define_record` instead of `:define_struct` in the `defrecord` or `defrecordp` definer options. + + Change this: + + typed_structor definer: :defrecord, record_name: :user, define_struct: true + + to this: + + typed_structor definer: :defrecord, record_name: :user, define_record: true + """) + + value + + :error -> + true + end + end) + + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + record_name = TypedStructor.Definer.Defrecord.__get_record_name__(definition) + record_tag = Keyword.get(definition.options, :record_tag) + + if define_record? do + {fields, enforce_keys} = Utils.fields_and_enforce_keys(definition) + + require Record + + case visibility do + :public -> + Record.defrecord(record_name, record_tag, fields) + + :private -> + Record.defrecordp(record_name, record_tag, fields) + end + end + end + end + + @doc false + defmacro __type_ast__(definition) do + quote bind_quoted: [definition: definition] do + {type_kind, type_name, parameters, fields} = Utils.types(definition, __ENV__) + # combine name and type annotations + fields = + Enum.map(fields, fn {name, type} -> + quote do: unquote(Macro.var(name, __MODULE__)) :: unquote(type) + end) + + record_tag = + Keyword.get_lazy(definition.options, :record_tag, fn -> + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + TypedStructor.Definer.Defrecord.__get_record_name__(definition) + end) + + case type_kind do + :type -> + @type unquote(type_name)(unquote_splicing(parameters)) :: + {unquote(record_tag), unquote_splicing(fields)} + + :opaque -> + @opaque unquote(type_name)(unquote_splicing(parameters)) :: + {unquote(record_tag), unquote_splicing(fields)} + + :typep -> + @typep unquote(type_name)(unquote_splicing(parameters)) :: + {unquote(record_tag), unquote_splicing(fields)} + end + end + end + + @doc false + def __get_record_name__(definition) do + case Keyword.fetch(definition.options, :record_name) do + {:ok, record_name} -> + record_name + + :error -> + raise ArgumentError, """ + Please provide the `:record_name` option when using the `defrecord` or `defrecordp` definer. + + Example: + + typed_structor definer: :defrecord, record_name: :user, define_record: true + """ + end + end + + @doc false + def __additional_options__, do: unquote(additional_options) +end diff --git a/lib/typed_structor/definer/defrecordp.ex b/lib/typed_structor/definer/defrecordp.ex new file mode 100644 index 0000000..c9a9492 --- /dev/null +++ b/lib/typed_structor/definer/defrecordp.ex @@ -0,0 +1,21 @@ +defmodule TypedStructor.Definer.Defrecordp do + @moduledoc """ + A definer to define private record macros and a type for a given definition. + + See more at `TypedStructor.Definer.Defrecord`. + """ + + alias TypedStructor.Definer.Defrecord + + @doc """ + Defines an exception and a type for a given definition. + """ + defmacro define(definition) do + quote do + require Defrecord + + Defrecord.__record_ast__(:private, unquote(definition)) + Defrecord.__type_ast__(unquote(definition)) + end + end +end diff --git a/lib/typed_structor/definer/defstruct.ex b/lib/typed_structor/definer/defstruct.ex index fa2dcb7..648b6db 100644 --- a/lib/typed_structor/definer/defstruct.ex +++ b/lib/typed_structor/definer/defstruct.ex @@ -1,10 +1,14 @@ defmodule TypedStructor.Definer.Defstruct do + additional_options = """ + * `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`. + """ + @moduledoc """ A definer to define a struct and a type for a given definition. ## Additional options for `typed_structor` - * `:define_struct` - if `false`, the type will be defined, but the struct will not be defined. Defaults to `true`. + #{additional_options} ## Usage @@ -18,6 +22,8 @@ defmodule TypedStructor.Definer.Defstruct do end """ + alias TypedStructor.Definer.Utils + @doc """ Defines a struct and a type for a given definition. """ @@ -30,12 +36,9 @@ defmodule TypedStructor.Definer.Defstruct do @doc false defmacro __struct_ast__(definition) do - alias TypedStructor.Definer.Defstruct - quote bind_quoted: [definition: definition] do if Keyword.get(definition.options, :define_struct, true) do - {fields, enforce_keys} = - Defstruct.__extract_fields_and_enforce_keys__(definition) + {fields, enforce_keys} = Utils.fields_and_enforce_keys(definition) @enforce_keys Enum.reverse(enforce_keys) defstruct fields @@ -43,50 +46,12 @@ defmodule TypedStructor.Definer.Defstruct do end end - @doc false - @spec __extract_fields_and_enforce_keys__(TypedStructor.Definition.t()) :: - {Keyword.t(), [atom()]} - def __extract_fields_and_enforce_keys__(definition) do - Enum.map_reduce(definition.fields, [], fn field, acc -> - name = Keyword.fetch!(field, :name) - default = Keyword.get(field, :default) - - if Keyword.get(field, :enforce, false) and not Keyword.has_key?(field, :default) do - {{name, default}, [name | acc]} - else - {{name, default}, acc} - end - end) - end - @doc false defmacro __type_ast__(definition) do quote bind_quoted: [definition: definition] do - fields = - Enum.reduce(definition.fields, [], fn field, acc -> - name = Keyword.fetch!(field, :name) - type = Keyword.fetch!(field, :type) - - if Keyword.get(field, :enforce, false) or Keyword.has_key?(field, :default) do - [{name, type} | acc] - else - [{name, quote(do: unquote(type) | nil)} | acc] - end - end) - - type_name = Keyword.get(definition.options, :type_name, :t) + {type_kind, type_name, parameters, fields} = Utils.types(definition, __ENV__) - parameters = - Enum.map( - definition.parameters, - fn parameter -> - parameter - |> Keyword.fetch!(:name) - |> Macro.var(__MODULE__) - end - ) - - case Keyword.get(definition.options, :type_kind, :type) do + case type_kind do :type -> @type unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{ unquote_splicing(fields) @@ -104,4 +69,7 @@ defmodule TypedStructor.Definer.Defstruct do end end end + + @doc false + def __additional_options__, do: unquote(additional_options) end diff --git a/lib/typed_structor/definer/utils.ex b/lib/typed_structor/definer/utils.ex new file mode 100644 index 0000000..3fc8dbc --- /dev/null +++ b/lib/typed_structor/definer/utils.ex @@ -0,0 +1,72 @@ +defmodule TypedStructor.Definer.Utils do + @moduledoc """ + Utilities for definer modules. + """ + + @doc """ + Extracts fields and enforce keys from a definition. + """ + @spec fields_and_enforce_keys(TypedStructor.Definition.t()) :: + {Keyword.t(), [atom()]} + def fields_and_enforce_keys(definition) do + Enum.map_reduce(definition.fields, [], fn field, acc -> + name = Keyword.fetch!(field, :name) + default = Keyword.get(field, :default) + + if get_keyword_value(field, :enforce, definition.options, false) and + not Keyword.has_key?(field, :default) do + {{name, default}, [name | acc]} + else + {{name, default}, acc} + end + end) + end + + @doc """ + Extracts types from a definition. + """ + @spec types(TypedStructor.Definition.t(), Macro.Env.t()) :: { + type_kind :: :type | :opaque | :typep, + type_name :: atom(), + parameters :: [{parameter_name :: atom(), [], context :: atom()}], + fields :: [{field_name :: atom(), Macro.t()}] + } + def types(definition, caller) do + type_kind = Keyword.get(definition.options, :type_kind, :type) + type_name = Keyword.get(definition.options, :type_name, :t) + + parameters = + Enum.map( + definition.parameters, + fn parameter -> + parameter + |> Keyword.fetch!(:name) + |> Macro.var(caller.module) + end + ) + + fields = + Enum.map(definition.fields, fn field -> + name = Keyword.fetch!(field, :name) + type = Keyword.fetch!(field, :type) + + if get_keyword_value(field, :enforce, definition.options, false) or + Keyword.has_key?(field, :default) do + {name, type} + else + {name, quote(do: unquote(type) | nil)} + end + end) + + {type_kind, type_name, parameters, fields} + end + + @spec get_keyword_value(Keyword.t(val), atom(), Keyword.t(val), val) :: val + when val: Keyword.value() + defp get_keyword_value(kv, key, options, default) + when is_list(kv) and is_atom(key) and is_list(options) do + Keyword.get_lazy(kv, key, fn -> + Keyword.get(options, key, default) + end) + end +end diff --git a/lib/typed_structor/definition.ex b/lib/typed_structor/definition.ex index 7911142..fe22ae7 100644 --- a/lib/typed_structor/definition.ex +++ b/lib/typed_structor/definition.ex @@ -10,5 +10,6 @@ defmodule TypedStructor.Definition do parameters: [Keyword.t()] } + @enforce_keys [:options, :fields, :parameters] defstruct [:options, :fields, :parameters] end diff --git a/mix.exs b/mix.exs index b1d1be3..8ba2a74 100644 --- a/mix.exs +++ b/mix.exs @@ -43,6 +43,9 @@ defmodule TypedStructor.MixProject do "guides/plugins/primary_key_and_timestamps.md", "guides/plugins/derive_jason.md", "guides/plugins/derive_enumerable.md" + ], + nest_modules_by_prefix: [ + TypedStructor.Definer ] ], package: [ diff --git a/test/definer_test.exs b/test/definer_test.exs index 601490b..bceaef7 100644 --- a/test/definer_test.exs +++ b/test/definer_test.exs @@ -1,6 +1,7 @@ defmodule DefinerTest do @compile {:no_warn_undefined, __MODULE__.Struct} @compile {:no_warn_undefined, __MODULE__.MyException} + @compile {:no_warn_undefined, __MODULE__.MyRecord} use TypedStructor.TestCase, async: true @@ -145,4 +146,142 @@ defmodule DefinerTest do cleanup_modules([__MODULE__.MyException], ctx.tmp_dir) end end + + describe "defrecord" do + @tag :tmp_dir + test "works", ctx do + expected_types = + with_tmpmodule MyRecord, ctx do + import Record + + @type t(age) :: { + :user, + name :: String.t() | nil, + age :: age | nil + } + + defrecord(:user, name: nil, age: nil) + after + fetch_types!(MyRecord) + end + + generated_types = + with_tmpmodule MyRecord, ctx do + use TypedStructor + + typed_structor definer: :defrecord, record_name: :user do + parameter :age + + field :name, String.t() + field :age, age + end + after + assert [user: 0, user: 1, user: 2] === MyRecord.__info__(:macros) + + assert {:user, "Phil", 20} === + eval( + quote do + require MyRecord + MyRecord.user(name: "Phil", age: 20) + end + ) + + fetch_types!(MyRecord) + end + + assert_type expected_types, generated_types + end + + @tag :tmp_dir + test "missing record_name", ctx do + assert_raise ArgumentError, + ~r/Please provide the `:record_name` option when using the `defrecord` or `defrecordp` definer/, + fn -> + defmodule MyRecord do + use TypedStructor + + typed_structor definer: :defrecord do + field :name, String.t() + field :age, pos_integer() + end + end + end + end + + @tag :tmp_dir + test "with record_tag", ctx do + expected_types = + with_tmpmodule MyRecord, ctx do + import Record + + @type t() :: { + User, + name :: String.t() | nil, + age :: pos_integer() | nil + } + + defrecord(:user, name: nil, age: nil) + after + fetch_types!(MyRecord) + end + + generated_types = + with_tmpmodule MyRecord, ctx do + use TypedStructor + + typed_structor definer: :defrecord, record_name: :user, record_tag: User do + field :name, String.t() + field :age, pos_integer() + end + after + assert [user: 0, user: 1, user: 2] === MyRecord.__info__(:macros) + + assert {User, "Phil", 20} === + eval( + quote do + require MyRecord + MyRecord.user(name: "Phil", age: 20) + end + ) + + fetch_types!(MyRecord) + end + + assert_type expected_types, generated_types + end + + @tag :tmp_dir + test "define_record false", ctx do + deftmpmodule MyRecord, ctx do + import Record + + use TypedStructor + + typed_structor definer: :defrecord, define_record: false, record_name: :user do + parameter :age + + field :name, String.t() + field :age, age + end + + defrecord(:user, name: "Phil", age: 20) + end + + assert [user: 0, user: 1, user: 2] === MyRecord.__info__(:macros) + + assert {:user, "Phil", 20} === + eval( + quote do + require MyRecord + MyRecord.user(name: "Phil", age: 20) + end + ) + after + cleanup_modules([__MODULE__.MyRecord], ctx.tmp_dir) + end + end + + defp eval(quoted) do + quoted |> Code.eval_quoted() |> elem(0) + end end diff --git a/test/guides/plugins/reflection_test.exs b/test/guides/plugins/reflection_test.exs index 1292b80..3061b59 100644 --- a/test/guides/plugins/reflection_test.exs +++ b/test/guides/plugins/reflection_test.exs @@ -22,7 +22,7 @@ defmodule Guides.Plugins.ReflectionTest do assert [enforce: true, name: :name, type: type] = User.__typed_structor__(:field, :name) assert "String.t()" === Macro.to_string(type) - assert [enforce: true, name: :age, type: type] = + assert [name: :age, type: type] = MyApp.User.__typed_structor__(:field, :age) assert "integer()" === Macro.to_string(type) diff --git a/test/typed_structor/definer/custom_definer_test.exs b/test/typed_structor/definer/custom_definer_test.exs new file mode 100644 index 0000000..fa5df1e --- /dev/null +++ b/test/typed_structor/definer/custom_definer_test.exs @@ -0,0 +1,75 @@ +defmodule TypedStructor.Definer.CustomDefinerTest do + use TypedStructor.TestCase, async: true + + defmodule MyDefiner do + defmacro define(definition) do + quote do + def definition, do: unquote(definition) + end + end + end + + defmodule Plugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _plugin_opts) do + quote do + Map.update!(unquote(definition), :options, fn options -> + Keyword.put(options, :definer, MyDefiner) + end) + end + end + end + + test "custom definer works" do + defmodule MyStruct do + use TypedStructor + + typed_structor definer: MyDefiner do + field :name, String.t() + end + end + + assert function_exported?(MyStruct, :definition, 0) + refute function_exported?(MyStruct, :__struct__, 0) + end + + test "raises when invalid definer is given" do + assert_raise ArgumentError, ~r/Definer must be one of/, fn -> + defmodule InvalidDefiner do + use TypedStructor + + typed_structor definer: 1 do + field :name, String.t() + end + end + end + end + + if function_exported?(Code, :with_diagnostics, 1) do + test "warns when definer is overridden" do + {_result, [warning]} = + Code.with_diagnostics(fn -> + defmodule DefinerWarning do + use TypedStructor + + typed_structor do + plugin Plugin + + field :name, String.t() + end + end + end) + + assert match?( + %{ + message: + "The definer option set in the `typed_structor` block is different from the definer option in the definition" <> + _message + }, + warning + ) + end + end +end diff --git a/test/typed_structor/definer/doc_test.exs b/test/typed_structor/definer/doc_test.exs new file mode 100644 index 0000000..c7de10d --- /dev/null +++ b/test/typed_structor/definer/doc_test.exs @@ -0,0 +1,13 @@ +defmodule TypedStructor.Definer.DocTest do + use ExUnit.Case, async: true + + alias TypedStructor.Definer.Defexception + alias TypedStructor.Definer.Defrecord + alias TypedStructor.Definer.Defstruct + + test "works" do + assert Defexception.__additional_options__() + assert Defrecord.__additional_options__() + assert Defstruct.__additional_options__() + end +end diff --git a/test/typed_structor/definer/utils_test.exs b/test/typed_structor/definer/utils_test.exs new file mode 100644 index 0000000..6912460 --- /dev/null +++ b/test/typed_structor/definer/utils_test.exs @@ -0,0 +1,134 @@ +defmodule TypedStructor.Definer.UtilsTest do + use ExUnit.Case, async: true + + alias TypedStructor.Definer.Utils + alias TypedStructor.Definition + + describe "fields_and_enforce_keys" do + test "works" do + definition = + build_definitions( + fields: [ + [name: :field], + [name: :enforce_true_with_default, enforce: true, default: :foo], + [name: :enforce_true_without_default, enforce: true], + [name: :enforce_false_with_default, enforce: false, default: :foo], + [name: :enforce_false_without_default, enforce: false] + ] + ) + + assert {[ + field: nil, + enforce_true_with_default: :foo, + enforce_true_without_default: nil, + enforce_false_with_default: :foo, + enforce_false_without_default: nil + ], + [:enforce_true_without_default]} === + Utils.fields_and_enforce_keys(definition) + end + end + + describe "types" do + test "works without parameters" do + definition = + build_definitions( + options: [type_kind: :typep, type_name: :state], + fields: [ + [name: :field, type: quote(do: atom())], + [ + name: :enforce_true_with_default, + type: quote(do: atom()), + enforce: true, + default: :foo + ], + [ + name: :enforce_true_without_default, + type: quote(do: atom()), + enforce: true + ], + [ + name: :enforce_false_with_default, + type: quote(do: atom()), + enforce: false, + default: :foo + ], + [ + name: :enforce_false_without_default, + type: quote(do: atom()), + enforce: false + ] + ] + ) + + assert { + :typep, + :state, + [], + [ + field: quote(do: atom() | nil), + enforce_true_with_default: quote(do: atom()), + enforce_true_without_default: quote(do: atom()), + enforce_false_with_default: quote(do: atom()), + enforce_false_without_default: quote(do: atom() | nil) + ] + } === Utils.types(definition, __ENV__) + end + + test "works with parameters" do + definition = + build_definitions( + options: [type_kind: :typep, type_name: :state], + parameters: [[name: :name], [name: :age]], + fields: [ + [name: :name, type: quote(do: name), enforce: true], + [name: :age, type: quote(do: age), enforce: true] + ] + ) + + assert { + :typep, + :state, + [ + Macro.var(:name, __MODULE__), + Macro.var(:age, __MODULE__) + ], + [ + name: quote(do: name), + age: quote(do: age) + ] + } === Utils.types(definition, __ENV__) + end + + test "works" do + definition = + build_definitions( + options: [type_kind: :typep, type_name: :state], + parameters: [[name: :role]], + fields: [ + [name: :name, type: quote(do: String.t()), enforce: true], + [name: :age, type: quote(do: pos_integer())], + [name: :role, type: quote(do: role), default: :user] + ] + ) + + assert { + :typep, + :state, + [{:role, [], TypedStructor.Definer.UtilsTest}], + [ + name: quote(do: String.t()), + age: quote(do: pos_integer() | nil), + role: quote(do: role) + ] + } === Utils.types(definition, __ENV__) + end + end + + defp build_definitions(params) do + struct( + Definition, + Keyword.merge([options: [], fields: [], parameters: []], params) + ) + end +end