diff --git a/.formatter.exs b/.formatter.exs index d304ff3..5c56972 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,10 @@ +locals_without_parens = [field: 2, field: 3, parameter: 1, plugin: 1, plugin: 2] + [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + import_deps: [:ecto], + locals_without_parens: locals_without_parens, + export: [ + locals_without_parens: locals_without_parens + ] ] diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index ef22908..d91e5f6 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -17,7 +17,6 @@ jobs: runs-on: ubuntu-latest env: FORCE_COLOR: 1 - MIX_ENV: test strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index ce3534f..f8f5cc7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ # TypedStructor -**TODO: Add description** +[![Build Status](https://github.com/elixir-typed-structor/typed_structor/actions/workflows/elixir.yml/badge.svg)](https://github.com/elixir-typed-structor/typed_structor/actions/workflows/elixir.yml) +[![Hex.pm](https://img.shields.io/hexpm/v/typed_structor.svg)](https://hex.pm/packages/typed_structor) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/typed_structor/) + +`TypedStructor` is a library for defining structs with types effortlessly. +(This library is a rewritten version of [TypedStruct](https://github.com/ejpcmac/typed_struct) because it is no longer actively maintained.) + + ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `typed_structor` to your list of dependencies in `mix.exs`: +Add `:typed_structor` to the list of dependencies in `mix.exs`: ```elixir def deps do @@ -15,7 +20,211 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +Add `:typed_structor` to your `.formatter.exs` file + +```elixir +[ + # import the formatter rules from `:typed_structor` + import_deps: [..., :typed_structor], + inputs: [...] +] +``` + +## Usage + +### General usage + +To define a struct with types, use `TypedStructor`, +and then define fields under the `TypedStructor.typed_structor/2` macro, +using the `TypedStructor.field/3` macro to define each field. + +```elixir +defmodule User do + # use TypedStructor to import the `typed_structor` macro + use TypedStructor + + typed_structor do + # Define each field with the `field` macro. + field :id, pos_integer() + + # set a default value + field :name, String.t(), default: "Unknown" + + # enforce a field + field :age, non_neg_integer(), enforce: true + end +end +``` +This is equivalent to: +```elixir +defmodule User do + defstruct [:id, :name, :age] + + @type t() :: %__MODULE__{ + id: pos_integer() | nil, + # Note: The 'name' can be nil, even though it has a default value. + name: String.t() | nil, + age: non_neg_integer() + } +end +``` +Check `TypedStructor.typed_structor/2` and `TypedStructor.field/3` for more information. +> #### `:enforce` and `:default` option {: .warning} +> Note that the `default` option does not affect the `enforce` option. +> If you want to enforce a field, you should explicitly set the `enforce` option to `true`. +> +> Consider the following example, `nil` is a valid value for the `:foo` field. +> +> ```elixir +> defmodule Settings do +> @enforce_keys [:foo] +> defstruct [foo: :bar] +> end +> +> %Settings{} # => ** (ArgumentError) the following keys must also be given when building struct Settings: [:foo] +> # `nil` is a valid value for the `:foo` field +> %Settings{foo: nil} # => %Settings{foo: nil} +> ``` + +### Options + +You can also generate an `opaque` type for the struct, +even changing the type name: + +```elixir +defmodule User do + use TypedStructor + + typed_structor type_kind: :opaque, type_name: :profile do + field :id, pos_integer() + field :name, String.t() + field :age, non_neg_integer() + end +end +``` +This is equivalent to: +```elixir +defmodule User do + use TypedStructor + + defstruct [:id, :name, :age] + + @opaque profile() :: %__MODULE__{ + id: pos_integer() | nil, + name: String.t() | nil, + age: non_neg_integer() | nil + } +end +``` + +Type parameters also can be defined: +```elixir +defmodule User do + use TypedStructor + + typed_structor do + parameter :id + parameter :name + + field :id, id + field :name, name + field :age, non_neg_integer() + end +end +``` +becomes: +```elixir +defmodule User do + @type t(id, name) :: %__MODULE__{ + id: id | nil, + name: name | nil, + age: non_neg_integer() | nil + } + + defstruct [:id, :name, :age] +end +``` + +If you prefer to define a struct in a submodule, pass the `module` option. +```elixir +defmodule User do + use TypedStructor + + # `%User.Profile{}` is generated + typed_structor module: Profile do + field :id, pos_integer() + field :name, String.t() + field :age, non_neg_integer() + end +end +``` + +You can define the type only without defining the struct, +it is useful when the struct is defined by another library(like `Ecto.Schema`). +```elixir +defmodule User do + use Ecto.Schema + use TypedStructor + + typed_structor define_struct: false do + field :id, pos_integer() + field :name, String.t() + field :age, non_neg_integer(), default: 0 # default value is useless in this case + end + + schema "users" do + field :name, :string + field :age, :integer, default: 0 + end +end +``` + +## Documentation + +To add a `@typedoc` to the struct type, just add the attribute in the typed_structor block: + +```elixir +typed_structor do + @typedoc "A typed user" + + field :id, pos_integer() + field :name, String.t() + field :age, non_neg_integer() +end +``` +You can also document submodules this way: + +```elixir +typedstruct module: Profile do + @moduledoc "A user profile struct" + @typedoc "A typed user profile" + + field :id, pos_integer() + field :name, String.t() + field :age, non_neg_integer() +end +``` + +## Plugins + +`TypedStructor` offers a plugin system to enhance functionality. +For details on creating a plugin, refer to the `TypedStructor.Plugin` module. + +Here is a example of `TypedStructor.Plugins.Accessible` plugin to define `Access` behavior for the struct. +```elixir +defmodule User do + use TypedStructor + + typed_structor do + plugin TypedStructor.Plugins.Accessible + + field :id, pos_integer() + field :name, String.t() + field :age, non_neg_integer() + end +end + +user = %User{id: 1, name: "Phil", age: 20} +get_in(user, [:name]) # => "Phil" +``` diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index eab7a3d..c6ed640 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -1,3 +1,350 @@ defmodule TypedStructor do - @moduledoc false + @external_resource "README.md" + @moduledoc "README.md" + |> File.read!() + |> String.split("", parts: 2) + |> Enum.fetch!(1) + + defmacro __using__(_opts) do + quote do + import TypedStructor, only: [typed_structor: 1, typed_structor: 2] + end + end + + @doc """ + Defines a struct with type information. + + Inside a `typed_structor` block, you can define fields with the `field/3` macro. + + ## Options + + * `:module` - if provided, the struct will be defined in the given module. + * `:enforce` - if `true`, the struct will enforce the keys. + * `:define_struct` - if `false`, the struct will not be defined. Defaults to `true`. + * `:type_kind` - the kind of type to use for the struct. Defaults to `type`, can be `opaque` or `typep`. + * `:type_name` - the name of the type to use for the struct. Defaults to `t`. + + ## Examples + + defmodule MyStruct do + use TypedStructor + + typed_structor do + field :name, String.t() + field :age, integer() + end + end + + Creates the struct in a submodule instead: + + defmodule MyStruct do + use TypedStructor + + typed_structor module: Struct do + field :name, String.t() + field :age, integer() + end + end + + To add a `@typedoc` to the struct type and `@moduledoc` to the submodule, + just add the module attribute in the `typed_structor` block: + + defmodule MyStruct do + use TypedStructor + + typed_structor module: Struct do + @typedoc "A typed struct" + @moduledoc "A submodule" + + field :name, String.t() + field :age, integer() + end + end + """ + defmacro typed_structor(options \\ [], do: block) when is_list(options) do + definition = + quote do + {module, options} = Keyword.pop(unquote(options), :module, __MODULE__) + + Module.register_attribute(__MODULE__, :__ts_current_module__, accumulate: false) + Module.register_attribute(__MODULE__, :__ts_struct_fields_acc__, accumulate: true) + Module.register_attribute(__MODULE__, :__ts_struct_parameters_acc__, accumulate: true) + Module.register_attribute(__MODULE__, :__ts_struct_plugins_acc__, accumulate: true) + + @__ts_current_module__ {module, options} + + # create a lexical scope + try do + import TypedStructor, only: [field: 2, field: 3, parameter: 1, plugin: 1, plugin: 2] + unquote(block) + + fields = Enum.reverse(@__ts_struct_fields_acc__) + parameters = Enum.reverse(@__ts_struct_parameters_acc__) + + Module.delete_attribute(__MODULE__, :__ts_struct_fields_acc__) + Module.delete_attribute(__MODULE__, :__ts_struct_parameters_acc__) + + @__ts_struct_plugins__ Enum.reverse(@__ts_struct_plugins_acc__) + Module.delete_attribute(__MODULE__, :__ts_struct_plugins_acc__) + + definition = + TypedStructor.__call_plugins_before_definitions__(%TypedStructor.Definition{ + options: options, + fields: fields, + parameters: parameters + }) + + @__ts_definition__ definition + @__ts_current_module__ {module, definition.options} + + TypedStructor.__struct_ast__() + TypedStructor.__type_ast__() + TypedStructor.__reflection_ast__() + after + :ok + end + end + + ast = + quote do + unquote(definition) + + # create a lexical scope + try do + TypedStructor.__call_plugins_after_definitions__() + after + # cleanup + Module.delete_attribute(__MODULE__, :__ts_struct_plugins__) + Module.delete_attribute(__MODULE__, :__ts_definition__) + Module.delete_attribute(__MODULE__, :__ts_current_module__) + end + end + + case Keyword.fetch(options, :module) do + {:ok, module} -> + quote do + defmodule unquote(module) do + unquote(ast) + end + end + + :error -> + ast + end + end + + @doc """ + Defines a field in a `typed_structor`. + + ## Example + + # A field named :example of type String.t() + field :example, String.t() + + ## Options + + * `:default` - sets the default value for the field + * `:enforce` - if set to true, enforces the field and makes its type + non-nullable + """ + defmacro field(name, type, options \\ []) do + options = Keyword.merge(options, name: name, type: Macro.escape(type)) + + quote do + {_module, options} = @__ts_current_module__ + + @__ts_struct_fields_acc__ Keyword.merge(options, unquote(options)) + end + end + + @doc """ + Defines a type parameter in a `typed_structor`. + + ## Example + + # A type parameter named int + parameter :int + + fied :number, int # not int() + """ + defmacro parameter(name) when is_atom(name) do + quote do + @__ts_struct_parameters_acc__ unquote(name) + end + end + + defmacro parameter(name) do + raise ArgumentError, "expected an atom, got: #{inspect(name)}" + end + + @doc """ + Registers a plugin for the currently defined struct. + + ## Example + + typed_structor do + plugin MyPlugin + + field :string, String.t() + end + + For more information on how to define your own plugins, please see + `TypedStructor.Plugin`. To use a third-party plugin, please refer directly to + its documentation. + """ + defmacro plugin(plugin, opts \\ []) do + quote do + require unquote(plugin) + + unquote(plugin).init(unquote(opts)) + + @__ts_struct_plugins_acc__ { + unquote(plugin), + unquote(opts), + { + # workaround to resolve these issues: + # 1. warning: variable '&1' is unused (this might happen when using a capture argument as a pattern) + # 2. error: invalid argument for require, expected a compile time atom or alias, got: plugin + fn definition, opts -> unquote(plugin).before_definition(definition, opts) end, + fn definition, opts -> unquote(plugin).after_definition(definition, opts) end + } + } + end + end + + @doc false + defmacro __struct_ast__ do + ast = + quote do + {fields, enforce_keys} = + Enum.map_reduce(@__ts_definition__.fields, [], fn field, acc -> + name = Keyword.fetch!(field, :name) + default = Keyword.get(field, :default) + + if Keyword.get(field, :enforce, false) do + {{name, default}, [name | acc]} + else + {{name, default}, acc} + end + end) + + @enforce_keys Enum.reverse(enforce_keys) + defstruct fields + end + + quote do + {_module, options} = @__ts_current_module__ + + if Keyword.get(options, :define_struct, true) do + unquote(ast) + end + end + end + + @doc false + defmacro __type_ast__ do + quote unquote: false do + {module, options} = @__ts_current_module__ + + fields = + Enum.reduce(@__ts_definition__.fields, [], fn field, acc -> + name = Keyword.fetch!(field, :name) + type = Keyword.fetch!(field, :type) + + if Keyword.get(field, :enforce, false) do + [{name, type} | acc] + else + [{name, quote(do: unquote(type) | nil)} | acc] + end + end) + + type_name = Keyword.get(options, :type_name, :t) + + parameters = Enum.map(@__ts_definition__.parameters, &Macro.var(&1, __MODULE__)) + + case Keyword.get(options, :type_kind, :type) do + :type -> + @type unquote(type_name)(unquote_splicing(parameters)) :: %unquote(module){ + unquote_splicing(fields) + } + + :opaque -> + @opaque unquote(type_name)(unquote_splicing(parameters)) :: %unquote(module){ + unquote_splicing(fields) + } + + :typep -> + @typep unquote(type_name)(unquote_splicing(parameters)) :: %unquote(module){ + unquote_splicing(fields) + } + end + end + end + + @doc false + defmacro __reflection_ast__ do + quote unquote: false do + fields = Enum.map(@__ts_definition__.fields, &Keyword.fetch!(&1, :name)) + + enforced_fields = + @__ts_definition__.fields + |> Stream.filter(&Keyword.get(&1, :enforce, false)) + |> Stream.map(&Keyword.fetch!(&1, :name)) + |> Enum.to_list() + + def __typed_structor__(:fields), do: unquote(fields) + def __typed_structor__(:parameters), do: @__ts_definition__.parameters + def __typed_structor__(:enforced_fields), do: unquote(enforced_fields) + + for field <- @__ts_definition__.fields do + name = Keyword.fetch!(field, :name) + type = field |> Keyword.fetch!(:type) |> Macro.escape() + + def __typed_structor__(:type, unquote(name)), do: unquote(type) + def __typed_structor__(:field, unquote(name)), do: unquote(Macro.escape(field)) + end + end + end + + defmacro __call_plugins_before_definitions__(definition) do + alias TypedStructor.Definition + + quote do + Enum.reduce( + @__ts_struct_plugins__, + unquote(definition), + fn {plugin, opts, {before_definition, _after_definition}}, acc -> + result = before_definition.(acc, opts) + + result + |> List.wrap() + |> Enum.filter(&is_struct(&1, Definition)) + |> case do + [definition] -> + definition + + _otherwise -> + raise """ + The plugin call to `#{inspect(plugin)}` did not return a `#{inspect(Definition)}` struct, + got: #{inspect(result)} + + The plugin call should return a `#{inspect(Definition)}` struct, + or a list which contains exactly one `#{inspect(Definition)}` struct. + """ + end + end + ) + end + end + + defmacro __call_plugins_after_definitions__ do + quote do + Enum.each( + Enum.reverse(@__ts_struct_plugins__), + fn {plugin, opts, {_before_definition, after_definition}} -> + after_definition.(@__ts_definition__, opts) + end + ) + end + end end diff --git a/lib/typed_structor/definition.ex b/lib/typed_structor/definition.ex new file mode 100644 index 0000000..389e614 --- /dev/null +++ b/lib/typed_structor/definition.ex @@ -0,0 +1,14 @@ +defmodule TypedStructor.Definition do + @moduledoc """ + The definition struct that holds the `TypedStructor` options, + fields and parameters. + """ + + @type t() :: %__MODULE__{ + options: Keyword.t(), + fields: [Keyword.t()], + parameters: [atom()] + } + + defstruct [:options, :fields, :parameters] +end diff --git a/lib/typed_structor/plugin.ex b/lib/typed_structor/plugin.ex new file mode 100644 index 0000000..11f7a25 --- /dev/null +++ b/lib/typed_structor/plugin.ex @@ -0,0 +1,282 @@ +defmodule TypedStructor.Plugin do + @moduledoc """ + This module defines the plugin behaviour for `TypedStructor`. + + ## Plugin Behaviour + + A plugin is a module that implements the `TypedStructor.Plugin` behaviour. + Three macro callbacks are available for injecting code at different stages: + + * `c:init/1`: This macro callback is called when the plugin is used. + * `c:before_definition/2`: This macro callback is called right before defining the struct. + Note hat plugins will run in the order they are registered. + * `c:after_definition/2`: This macro callback is called right after defining the struct. + Note that plugins will run in the **reverse** order they are registered. + + ### Example + + Let's define a plugin that defines `Ecto.Schema` while defining a typed struct. + This plugin takes a `:source` option which passing to `Ecto.Schema.schema/2`, + you can use `belongs_to` and `has_many` directly in the module. + It would be used like this: + ```elixir + defmodule MyApp do + use TypedStructor + + # fix aliases + alias __MODULE__.User + alias __MODULE__.Post + + typed_structor module: User do + # import the plugin with source option + plugin EctoSchemaPlugin, source: "users" + + field :name, :string, enforce: true + field :age, :integer, default: 0 + # pass redact option to Ecto.Schema.field/3 + field :password, :string, redact: true + has_many :posts, Post + end + + typed_structor module: Post do + # import the plugin with source option + plugin EctoSchemaPlugin, source: "posts" + + field :title, :string, enforce: true + field :content, :string, enforce: true + belongs_to :user, User, enforce: true + end + end + ``` + After compiled, you got: + ```elixir + iex> t MyApp.User + @type t() :: %MyApp.User{ + __meta__: Ecto.Schema.Metadata.t(), + age: integer() | nil, + id: integer(), name: String.t(), + password: String.t() | nil, + posts: [MyApp.Post.t()] | nil + } + + iex> t MyApp.Post + @type t() :: %MyApp.Post{ + __meta__: Ecto.Schema.Metadata.t(), + content: String.t(), + id: integer(), + title: String.t(), + user: MyApp.User.t(), + user_id: integer() + } + + iex> MyApp.User.__schema__(:redact_fields) + [:password] + + iex> MyApp.User.__schema__(:association, :posts) + %Ecto.Association.Has{ + cardinality: :many, + field: :posts, + owner: MyApp.User, + related: MyApp.Post, + owner_key: :id, + related_key: :user_id, + on_cast: nil, + queryable: MyApp.Post, + on_delete: :nothing, on_replace: :raise, + where: [], + unique: true, + defaults: [], + relationship: :child, + ordered: false, + preload_order: [] + } + + iex> MyApp.Post.__schema__(:association, :user) + %Ecto.Association.BelongsTo{ + field: :user, + owner: MyApp.Post, + related: MyApp.User, + owner_key: :user_id, + related_key: :id, + queryable: MyApp.User, + on_cast: nil, + on_replace: :raise, + where: [], + defaults: [], + cardinality: :one, + relationship: :parent, + unique: true, + ordered: false + } + ``` + Following is the implementation of the plugin: + ```elixir + defmodule EctoSchemaPlugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro init(opts) do + quote do + unless Keyword.has_key?(unquote(opts), :source) do + raise "The `:source` option is not provided." + end + + # import association functions to the module, + # so that we can use `has_many` and `belongs_to` directly + import unquote(__MODULE__), only: [has_many: 2, belongs_to: 3] + end + end + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _opts) do + # manipulate the definition before defining the struct + quote do + unquote(definition) + # disable defining struct, for Ecto.Schema will define it + |> Map.update!(:options, &Keyword.put(&1, :define_struct, false)) + |> Map.update!(:fields, fn fields -> + Enum.flat_map(fields, fn field -> + {ecto_type, options} = Keyword.pop!(field, :type) + type = unquote(__MODULE__).__ecto_type_to_type__(ecto_type) + + field = Keyword.merge(options, type: type, ecto_type: ecto_type) + + case ecto_type do + {:belongs_to, name} -> + foreign_key_name = + name + |> Macro.expand(__ENV__) + |> Module.split() + |> List.last() + |> Macro.underscore() + |> Kernel.<>("_id") + |> String.to_atom() + + foreign_key = + Keyword.merge(options, name: foreign_key_name, type: quote(do: integer())) + + [foreign_key, field] + + _other -> + [field] + end + end) + end) + |> Map.update!( + :fields, + &[ + [name: :__meta__, type: quote(do: Ecto.Schema.Metadata.t()), enforce: true], + [name: :id, type: quote(do: integer()), enforce: true] + | &1 + ] + ) + end + end + + @impl TypedStructor.Plugin + defmacro after_definition(definition, opts) do + # here we define the Ecto.Schema + quote bind_quoted: [definition: definition, opts: opts] do + use Ecto.Schema + + source = Keyword.fetch!(opts, :source) + + schema source do + for options <- definition.fields do + {name, options} = Keyword.pop!(options, :name) + {ecto_type, options} = Keyword.pop(options, :ecto_type) + options = Keyword.take(options, [:primary_key, :default, :redact]) + + case ecto_type do + nil -> + # skip some fields + nil + + {:has_many, module} -> + module = Macro.expand(module, __ENV__) + + Ecto.Schema.has_many(name, module, options) + + {:belongs_to, module} -> + module = Macro.expand(module, __ENV__) + + Ecto.Schema.belongs_to(name, module, options) + + _ -> + Ecto.Schema.field(name, ecto_type, options) + end + end + end + end + end + + defmacro has_many(name, queryable) do + quote do + field unquote(name), {:has_many, unquote(queryable)} + end + end + + defmacro belongs_to(name, queryable, opts) do + quote do + field unquote(name), {:belongs_to, unquote(queryable)}, unquote(opts) + end + end + + def __ecto_type_to_type__(:string), do: quote(do: String.t()) + def __ecto_type_to_type__(:integer), do: quote(do: integer()) + def __ecto_type_to_type__({:has_many, module}), do: quote(do: [unquote(module).t()]) + def __ecto_type_to_type__({:belongs_to, module}), do: quote(do: unquote(module).t()) + end + ``` + """ + + @doc """ + This macro callback is called when the plugin is used. + + Here you can define module attributes, import modules, etc. + """ + @macrocallback init(plugin_opts :: Keyword.t()) :: Macro.t() + + @doc """ + This macro callback is called right before defining the struct. + + It receives the definition of the struct and the plugin options, + and it should return the `TypedStructor.Definition` struct or + a list which contains exactly one `TypedStructor.Definition` struct. + """ + @macrocallback before_definition( + definition :: TypedStructor.Definition.t(), + plugin_opts :: Keyword.t() + ) :: + Macro.t() + + @doc """ + This macro callback is called right after defining the struct. + + It receives the definition of the struct and the plugin options, + and its return value is ignored. + """ + @macrocallback after_definition( + definition :: TypedStructor.Definition.t(), + plugin_opts :: Keyword.t() + ) :: + Macro.t() + + @doc false + defmacro __using__(_opts) do + quote do + @behaviour TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro init(_opts), do: nil + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _opts), do: definition + + @impl TypedStructor.Plugin + defmacro after_definition(_definition, _opts), do: nil + + defoverridable init: 1, before_definition: 2, after_definition: 2 + end + end +end diff --git a/lib/typed_structor/plugins/accessible.ex b/lib/typed_structor/plugins/accessible.ex new file mode 100644 index 0000000..6fbed2b --- /dev/null +++ b/lib/typed_structor/plugins/accessible.ex @@ -0,0 +1,55 @@ +defmodule TypedStructor.Plugins.Accessible do + @moduledoc """ + This plugin implements the `Access` behavior for the struct + by delegating the `fetch/2`, `get_and_update/3`, and `pop/2` + functions to the `Map` module. + + > #### Destructive operations {: .warning} + > These operations are not allowed for the struct: + > * update `:__struct__` key + > * pop a key + > + > The functions will raise an `ArgumentError` if called. + > To enable these functionalities, override the `get_and_update/3` and `pop/2` functions. + + ## Usage + + ```elixir + typed_structor do + plugin TypedStructor.Plugins.Accessible + + # fields + end + ``` + """ + + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro after_definition(_definition, _opts) do + quote do + @behaviour Access + + @impl Access + defdelegate fetch(term, key), to: Map + + @impl Access + def get_and_update(data, :__struct__, _function) do + raise ArgumentError, + "Cannot update `:__struct__` key." <> + "To enable this functionality, implement `Access.get_and_update/3` for #{inspect(__MODULE__)} to override this behaviour." + end + + defdelegate get_and_update(data, key, function), to: Map + + @impl Access + def pop(data, key) do + raise ArgumentError, + "Cannot pop `#{inspect(key)}` key.\n" <> + "To enable this functionality, implement `Access.pop/2` for #{inspect(__MODULE__)} to override this behaviour." + end + + @defoverridable Access + end + end +end diff --git a/mix.exs b/mix.exs index 94fdbf8..5841f5b 100644 --- a/mix.exs +++ b/mix.exs @@ -6,8 +6,16 @@ defmodule TypedStructor.MixProject do app: :typed_structor, version: "0.1.0", elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + name: "TypedStructor", + source: "https://github.com/elixir-typed-structor/typed_structor", + homepage_url: "https://github.com/elixir-typed-structor/typed_structor", + docs: [ + main: "TypedStructor", + extras: ["README.md"] + ] ] end @@ -19,8 +27,13 @@ defmodule TypedStructor.MixProject do defp deps do [ + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:ecto, "~> 3.0", only: [:dev, :test], optional: true} ] end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] end diff --git a/mix.lock b/mix.lock index 31422bb..b5914fb 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,17 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [: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", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_doc": {:hex, :ex_doc, "0.34.1", "9751a0419bc15bc7580c73fde506b17b07f6402a1e5243be9e0f05a68c723368", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d441f1a86a235f59088978eff870de2e815e290e44a8bd976fe5d64470a4c9d2"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, } diff --git a/test/reflection_test.exs b/test/reflection_test.exs new file mode 100644 index 0000000..02530fa --- /dev/null +++ b/test/reflection_test.exs @@ -0,0 +1,53 @@ +defmodule ReflectionTest do + use ExUnit.Case, async: true + + defmodule Struct do + use TypedStructor + + typed_structor do + parameter :age + + field :name, String.t(), enforce: true + field :age, age + end + end + + defmodule MyModule do + use TypedStructor + + typed_structor module: Struct, enforce: true do + field :name, String.t() + field :age, integer() + end + end + + test "generates reflection functions" do + assert [:name, :age] === Struct.__typed_structor__(:fields) + assert [:name, :age] === MyModule.Struct.__typed_structor__(:fields) + + assert [:age] === Struct.__typed_structor__(:parameters) + assert [] === MyModule.Struct.__typed_structor__(:parameters) + + assert [:name] === Struct.__typed_structor__(:enforced_fields) + assert [:name, :age] === MyModule.Struct.__typed_structor__(:enforced_fields) + + assert "String.t()" === Macro.to_string(Struct.__typed_structor__(:type, :name)) + assert "age" === Macro.to_string(Struct.__typed_structor__(:type, :age)) + + assert "String.t()" === Macro.to_string(MyModule.Struct.__typed_structor__(:type, :name)) + assert "integer()" === Macro.to_string(MyModule.Struct.__typed_structor__(:type, :age)) + + assert [enforce: true, name: :name, type: type] = Struct.__typed_structor__(:field, :name) + assert "String.t()" === Macro.to_string(type) + + assert [enforce: true, name: :age, type: type] = + MyModule.Struct.__typed_structor__(:field, :age) + + assert "integer()" === Macro.to_string(type) + + assert [enforce: true, name: :name, type: type] = Struct.__typed_structor__(:field, :name) + assert "String.t()" === Macro.to_string(type) + assert [name: :age, type: type] = Struct.__typed_structor__(:field, :age) + assert "age" === Macro.to_string(type) + end +end diff --git a/test/support/type_case.ex b/test/support/type_case.ex new file mode 100644 index 0000000..a5141ed --- /dev/null +++ b/test/support/type_case.ex @@ -0,0 +1,91 @@ +defmodule TypedStructor.TypeCase do + @moduledoc false + + use ExUnit.CaseTemplate + + setup do + Code.compiler_options(debug_info: true) + + :ok + end + + using do + quote do + import unquote(__MODULE__) + end + end + + defmacro deftmpmodule(do: block) do + quote do + {:module, module_name, bytecode, submodule} = + defmodule TestModule do + # credo:disable-for-previous-line Credo.Check.Readability.ModuleDoc + unquote(block) + end + + case submodule do + {:module, submodule_name, bytecode, _} -> + on_exit(fn -> + remove_module(module_name) + remove_module(submodule_name) + end) + + bytecode + + _other -> + on_exit(fn -> + remove_module(module_name) + end) + + bytecode + end + end + end + + defmacro test_module(do: block) do + quote do + {:module, module_name, bytecode, submodule} = + returning = + defmodule TestModule do + # credo:disable-for-previous-line Credo.Check.Readability.ModuleDoc + unquote(block) + end + + case submodule do + {:module, submodule_name, bytecode, _} -> + remove_module(module_name) + remove_module(submodule_name) + + bytecode + + _other -> + remove_module(module_name) + + bytecode + end + end + end + + def remove_module(module) do + :code.delete(module) + :code.purge(module) + end + + def types(module) when is_binary(module) or is_atom(module) do + module + |> Code.Typespec.fetch_types() + |> elem(1) + |> Enum.sort_by(fn {_, {name, _, args}} -> {name, length(args)} end) + |> Enum.map_join( + "\n", + fn {kind, type} -> + ast = Code.Typespec.type_to_quoted(type) + format_typespec(ast, kind) + end + ) + end + + defp format_typespec(ast, kind) do + "@#{kind} #{Macro.to_string(ast)}" + end +end diff --git a/test/typed_structor/plugin_test.exs b/test/typed_structor/plugin_test.exs new file mode 100644 index 0000000..62ad9c6 --- /dev/null +++ b/test/typed_structor/plugin_test.exs @@ -0,0 +1,110 @@ +defmodule TypedStructor.PluginTest do + use TypedStructor.TypeCase, async: true + + describe "callbacks order" do + for plugin <- [Plugin1, Plugin2] do + defmodule plugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro init(_plugin_opts) do + quote do + @plugin_calls {unquote(__MODULE__), :init} + end + end + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _plugin_opts) do + quote do + @plugin_calls {unquote(__MODULE__), :before_definition} + unquote(definition) + end + end + + @impl TypedStructor.Plugin + defmacro after_definition(_definition, _plugin_opts) do + quote do + @plugin_calls {unquote(__MODULE__), :after_definition} + end + end + end + end + + defmodule Struct do + use TypedStructor + Module.register_attribute(__MODULE__, :plugin_calls, accumulate: true) + + typed_structor do + plugin Plugin1 + plugin Plugin2 + + field :name, String.t() + end + + def plugin_calls, do: @plugin_calls + end + + test "callbacks are called by order" do + assert [ + {Plugin1, :after_definition}, + {Plugin2, :after_definition}, + {Plugin2, :before_definition}, + {Plugin1, :before_definition}, + {Plugin2, :init}, + {Plugin1, :init} + ] === Struct.plugin_calls() + end + end + + describe "before_definition/2" do + defmodule ManipulatePlugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _plugin_opts) do + quote do + Map.update!( + unquote(definition), + :fields, + fn fields -> + Enum.map(fields, fn field -> + {name, field} = Keyword.pop!(field, :name) + {type, field} = Keyword.pop!(field, :type) + name = name |> Atom.to_string() |> String.upcase() |> String.to_atom() + type = quote do: unquote(type) | atom() + + [{:name, name}, {:type, type} | field] + end) + end + ) + end + end + end + + test "manipulates definition" do + expected_bytecode = + test_module do + @type t() :: %TestModule{ + NAME: (String.t() | atom()) | nil + } + + defstruct [:NAME] + end + + expected_types = types(expected_bytecode) + + bytecode = + test_module do + use TypedStructor + + typed_structor do + plugin ManipulatePlugin + + field :name, String.t() + end + end + + assert expected_types === types(bytecode) + end + end +end diff --git a/test/typed_structor/plugins/accessible_test.exs b/test/typed_structor/plugins/accessible_test.exs new file mode 100644 index 0000000..d426807 --- /dev/null +++ b/test/typed_structor/plugins/accessible_test.exs @@ -0,0 +1,43 @@ +defmodule TypedStructor.Plugins.AccessibleTest do + use ExUnit.Case, async: true + + defmodule Struct do + use TypedStructor + + typed_structor do + plugin TypedStructor.Plugins.Accessible + + parameter :age + + field :name, String.t() + field :age, age + end + end + + describe "accessible option" do + test "implements Access" do + data = struct(Struct, name: "Phil", age: 20) + + assert "Phil" === get_in(data, [:name]) + assert %{name: "phil"} = put_in(data, [:name], "phil") + + assert_raise ArgumentError, ~r/Cannot update `:__struct__` key/, fn -> + put_in(data, [:__struct__], "phil") + end + + assert %{name: "phil"} = update_in(data, [:name], fn "Phil" -> "phil" end) + + assert_raise ArgumentError, ~r/Cannot update `:__struct__` key/, fn -> + update_in(data, [:__struct__], fn _ -> nil end) + end + + assert_raise ArgumentError, ~r/Cannot pop `:__struct__` key/, fn -> + pop_in(data, [:__struct__]) + end + + assert_raise ArgumentError, ~r/Cannot pop `:name` key/, fn -> + pop_in(data, [:name]) + end + end + end +end diff --git a/test/typed_structor_test.exs b/test/typed_structor_test.exs new file mode 100644 index 0000000..58c6914 --- /dev/null +++ b/test/typed_structor_test.exs @@ -0,0 +1,422 @@ +defmodule TypedStructorTest do + use TypedStructor.TypeCase, async: true + + test "generates the struct and the type" do + expected_bytecode = + test_module do + @type t() :: %TestModule{ + age: integer() | nil, + name: String.t() | nil + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor do + field :name, String.t() + field :age, integer() + end + end + + assert match?( + %{ + __struct__: TestModule, + name: nil, + age: nil + }, + struct(TestModule) + ) + + assert expected_types === types(bytecode) + end + + describe "module option" do + test "generates the struct and the type" do + expected_bytecode = + test_module do + defmodule Struct do + @type t() :: %TestModule.Struct{ + age: integer() | nil, + name: String.t() | nil + } + defstruct [:age, :name] + end + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor module: Struct do + field :name, String.t() + field :age, integer() + end + end + + assert match?( + %{ + __struct__: TestModule.Struct, + name: nil, + age: nil + }, + struct(TestModule.Struct) + ) + + assert expected_types === types(bytecode) + end + end + + describe "enforce option" do + test "set enforce on fields" do + expected_bytecode = + test_module do + @type t() :: %TestModule{ + name: String.t(), + age: integer() | nil + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor do + field :name, String.t(), enforce: true + field :age, integer() + end + end + + assert_raise_on_enforce_error(TestModule, [:name], fn -> + Code.eval_quoted(quote do: %TestModule{}) + end) + + assert expected_types === types(bytecode) + end + + test "set enforce on typed_structor" do + expected_bytecode = + test_module do + @type t() :: %TestModule{ + name: String.t(), + age: integer() + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor enforce: true do + field :name, String.t() + field :age, integer() + end + end + + assert_raise_on_enforce_error(TestModule, [:name, :age], fn -> + Code.eval_quoted(quote do: %TestModule{}) + end) + + assert expected_types === types(bytecode) + end + + test "overwrites the enforce option on fields" do + expected_bytecode = + test_module do + @type t() :: %TestModule{ + name: String.t(), + age: integer() | nil + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor enforce: true do + field :name, String.t() + field :age, integer(), enforce: false + end + end + + assert_raise_on_enforce_error(TestModule, [:name], fn -> + Code.eval_quoted(quote do: %TestModule{}) + end) + + assert expected_types === types(bytecode) + end + end + + describe "type_kind option" do + test "generates opaque type" do + expected_bytecode = + test_module do + @opaque t() :: %TestModule{ + name: String.t() | nil, + age: integer() | nil + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor type_kind: :opaque do + field :name, String.t() + field :age, integer() + end + end + + assert expected_types === types(bytecode) + end + + test "generates typep type" do + expected_bytecode = + test_module do + # suppress unused warning + @type external_t() :: t() + + @typep t() :: %TestModule{ + name: String.t() | nil, + age: integer() | nil + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + # suppress unused warning + @type external_t() :: t() + + typed_structor type_kind: :typep do + field :name, String.t() + field :age, integer() + end + end + + assert expected_types === types(bytecode) + end + end + + describe "type_name option" do + test "generates custom type_name type" do + expected_bytecode = + test_module do + @type test_type() :: %TestModule{ + name: String.t() | nil, + age: integer() | nil + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor type_name: :test_type do + field :name, String.t() + field :age, integer() + end + end + + assert expected_types === types(bytecode) + end + end + + describe "default option on the field" do + test "generates struct with default values" do + deftmpmodule do + use TypedStructor + + typed_structor do + field :name, String.t(), default: "Phil" + field :age, integer() + end + end + + assert match?( + %{ + __struct__: TestModule, + name: "Phil", + age: nil + }, + struct(TestModule) + ) + end + end + + describe "parameter" do + test "generates parameterized type" do + expected_bytecode = + test_module do + @type t(age) :: %TestModule{ + age: age | nil, + name: String.t() | nil + } + + defstruct [:age, :name] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor do + parameter :age + + field :name, String.t() + field :age, age + end + end + + assert match?( + %{ + __struct__: TestModule, + name: nil, + age: nil + }, + struct(TestModule) + ) + + assert expected_types === types(bytecode) + end + + test "generates ordered parameters for the type" do + expected_bytecode = + test_module do + @type t(age, name) :: %TestModule{ + age: age | nil, + name: name | nil + } + + defstruct [:name, :age] + end + + expected_types = types(expected_bytecode) + + bytecode = + deftmpmodule do + use TypedStructor + + typed_structor do + parameter :age + parameter :name + + field :name, name + field :age, age + end + end + + assert match?( + %{ + __struct__: TestModule, + name: nil, + age: nil + }, + struct(TestModule) + ) + + assert expected_types === types(bytecode) + end + end + + describe "define_struct option" do + test "implements Access" do + deftmpmodule do + use TypedStructor + + typed_structor define_struct: false do + parameter :age + + field :name, String.t() + field :age, age + end + + defstruct name: "Phil", age: 20 + end + + assert match?( + %{ + __struct__: TestModule, + name: "Phil", + age: 20 + }, + struct(TestModule) + ) + end + end + + describe "works with Ecto.Schema" do + test "works" do + deftmpmodule do + use TypedStructor + + typed_structor define_struct: false do + parameter :age + + field :name, String.t() + field :age, age + end + + use Ecto.Schema + + schema "source" do + field :name, :string + field :age, :integer, default: 20 + end + end + + assert [:id, :name, :age] === TestModule.__schema__(:fields) + + assert match?( + %{ + __struct__: TestModule, + name: nil, + age: 20 + }, + struct(TestModule) + ) + end + end + + defp assert_raise_on_enforce_error(module, keys, fun) do + assert_raise ArgumentError, + "the following keys must also be given when building struct #{inspect(module)}: #{inspect(keys)}", + fn -> + fun.() + end + end +end