diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aff77d..832e020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.2.0](https://github.com/elixir-typed-structor/typed_structor/compare/v0.1.5...v0.2.0) (2024-06-30) + + +### Features + +* register plugins globally by configuration ([4767e62](https://github.com/elixir-typed-structor/typed_structor/commit/4767e620c237777f535cfe763c773d13a4944c0f)) + ## 0.1.5 (2024-06-29) This release is a documentation update and diff --git a/guides/migrate_from_typed_struct.md b/guides/migrate_from_typed_struct.md index 33e61be..72a6703 100644 --- a/guides/migrate_from_typed_struct.md +++ b/guides/migrate_from_typed_struct.md @@ -6,17 +6,31 @@ It is a drop-in replacement for `typed_struct`. ## Migration 1. Replace `typed_struct` with `typed_structor` in your `mix.exs` file. + ```diff -- {:typed_struct, "~> 0.3"} -+ {:typed_structor, "~> 0.1"} + defp deps do + [ + # ...deps +- {:typed_struct, "~> 0.3.0"}, ++ {:typed_structor, "~> 0.1"}, + ] + end ``` + 2. Run `mix do deps.unlock --unused, deps.get, deps.clean --unused` to fetch the new dependency. 3. Replace `TypedStruct` with `TypedStructor` in your code. + ```diff + defmodule User do - use TypedStruct + use TypedStructor - -- typed_struct do + +- typedstruct do + typed_structor do + field :id, pos_integer() + field :name, String.t() + field :age, non_neg_integer() + end + end ``` 4. That's it! diff --git a/guides/plugins/derive_enumerable.md b/guides/plugins/derive_enumerable.md new file mode 100644 index 0000000..20972e8 --- /dev/null +++ b/guides/plugins/derive_enumerable.md @@ -0,0 +1,65 @@ +# Derives the `Enumerable` for `struct` + +We use the `c:TypedStructor.Plugin.after_definition/2` callback to +generate the `Enumerable` implementation for the struct. +We implement `Enumerable` callbacks exclusively using the fields that are defined. + +Let's start! + +## Implementation +```elixir +defmodule MyPlugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro after_definition(definition, _opts) do + quote bind_quoted: [definition: definition] do + keys = Enum.map(definition.fields, &Keyword.fetch!(&1, :name)) + + defimpl Enumerable do + def count(enumerable), do: {:ok, Enum.count(unquote(keys))} + def member?(enumerable, element), do: {:ok, Enum.member?(unquote(keys), element)} + + def reduce(enumerable, acc, fun) do + # The order of fields is guaranteed to align with the sequence in which they are defined. + unquote(keys) + |> Enum.map(fn key -> {key, Map.fetch!(enumerable, key)} end) + |> Enumerable.List.reduce(acc, fun) + end + + # We don't support this + def slice(_enumerable), do: {:error, __MODULE__} + end + end + end +end +``` + +## Usage +```elixir +defmodule User do + use TypedStructor + + typed_structor do + plugin MyPlugin + + field :name, String.t(), enforce: true + field :age, integer(), enforce: true + end +end +``` + +```elixir +iex> user = %User{name: "Phil", age: 20} +%User{name: "Phil", age: 20} +# the order of fields is deterministic +iex> Enum.map(user, fn {key, _value} -> key end) +[:name, :age] +# we got a deterministic ordered Keyword list +iex> Enum.to_list(user) +[name: "Phil", age: 20] +``` + +> #### Bonus {: .info} +> Additionally, we gain the bonus of having a deterministic order of fields, +> determined by the sequence in which they are defined. diff --git a/guides/plugins/derive_jason.md b/guides/plugins/derive_jason.md new file mode 100644 index 0000000..b2c4b3c --- /dev/null +++ b/guides/plugins/derive_jason.md @@ -0,0 +1,44 @@ +# Derives the `Jason.Encoder` for `struct` + +We use the `c:TypedStructor.Plugin.after_definition/2` callback to +generate the `Jason.Encoder` implementation for the struct. + +## Implementation +```elixir +defmodule MyPlugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro after_definition(definition, _opts) do + quote bind_quoted: [definition: definition] do + # Extract the field names + keys = Enum.map(definition.fields, &Keyword.fetch!(&1, :name)) + + defimpl Jason.Encoder do + def encode(value, opts) do + Jason.Encode.map(Map.take(value, unquote(keys)), opts) + end + end + end + end +end +``` + +## Usage +```elixir +defmodule User do + use TypedStructor + + typed_structor do + plugin MyPlugin + + field :name, String.t(), enforce: true + end +end +``` + +After compiled, you got: +```elixir +iex> Jason.encode(%User{name: "Phil"}) +{:ok, "{\"name\":\"Phil\"}"} +``` diff --git a/guides/plugins/introduction.md b/guides/plugins/introduction.md new file mode 100644 index 0000000..7c78178 --- /dev/null +++ b/guides/plugins/introduction.md @@ -0,0 +1,19 @@ +# Introduction to the plugin system + +For more customization, `TypedStructor` provides a plugin system +that allows you to extend the functionality of the library. +This is useful when you want to extract some common logic into a separate module. + +See `TypedStructor.Plugin` for how to create a plugin. + +This library comes with a few built-in plugins, and we don't like to +implement more built-in plugins, but instead, we encourage you to create your own plugins. +We provide some example plugins that you can use as a reference, or copy-paste. + +**Plugin examples:** +- [Registering plugins globally](./registering_plugins_globally.md) +- `TypedStructor.Plugins.Accessible` +- [Type Only on Ecto Schema](./type_only_on_ecto_schema.md) +- [Add primary key and timestamps types to your Ecto schema](./primary_key_and_timestamps.md) +- [Derives the `Jason.Encoder` for `struct`](./derive_jason.md) +- [Derives the `Enumerable` for `struct`](./derive_enumerable.md) diff --git a/guides/plugins/primary_key_and_timestamps.md b/guides/plugins/primary_key_and_timestamps.md new file mode 100644 index 0000000..55b64bd --- /dev/null +++ b/guides/plugins/primary_key_and_timestamps.md @@ -0,0 +1,58 @@ +# Add primary key and timestamps types to your Ecto schema + + +## Implementation + +This plugin use `c:TypedStructor.Plugin.before_definition/2` callback to +inject the primary key and timestamps fields to the type definition. + +```elixir +defmodule MyApp.TypedStructor.Plugins.PrimaryKeyAndTimestamps do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _opts) do + quote do + Map.update!(unquote(definition), :fields, fn fields -> + # Assume that the primary key is an integer + primary_key = [name: :id, type: quote(do: integer()), enforce: true] + + # Add two default timestamps + timestamps = [ + [name: :inserted_at, type: quote(do: NaiveDateTime.t()), enforce: true], + [name: :updated_at, type: quote(do: NaiveDateTime.t()), enforce: true] + ] + + [primary_key | fields] ++ timestamps + end) + end + end +end +``` + +## Usage + +```elixir +defmodule MyApp.User do + use TypedStructor + use Ecto.Schema + + # disable struct creation or it will conflict with the Ecto schema + typed_structor define_struct: false do + # register the plugin + plugin MyApp.TypedStructor.Plugins.PrimaryKeyAndTimestamps + + field :name, String.t() + field :age, integer(), enforce: true # There is always a non-nil value + end + + schema "source" do + field :name, :string + field :age, :integer, default: 20 + + timestamps() + end +end +``` + +If you want to apply this plugin conditionally, refer to the [Registering plugins globally](./registering_plugins_globally.md) section. diff --git a/guides/plugins/registering_plugins_globally.md b/guides/plugins/registering_plugins_globally.md new file mode 100644 index 0000000..4faa45a --- /dev/null +++ b/guides/plugins/registering_plugins_globally.md @@ -0,0 +1,54 @@ +# Registering plugins globally + +`TypedStructor` allows you to register plugins globally, so you don't have to specify them in each struct. +This is useful when you want to apply the same plugin to all modules that use `TypedStructor`. + +> #### Global plugins are applied to all modules {: .warning} +> The global registered plugins is applied to **all modules** that use `TypedStructor`. +> That means any dependency or library that uses `TypedStructor` will also be affected. +> +> If you want to apply the plugin to specific modules, you can determine the +> module name or other conditions in the plugin implementation. + + +## Usage + +To register a plugin globally, you can add it to the `:plugins` key in the `:typed_structor` app configuration. +```elixir +config :typed_structor, plugins: [MyPlugin, {MyPluginWithOpts, [foo: :bar]}] +``` + +## How to opt-out the plugin conditionally + +The most common way to opt-out a plugin is to determine the module name. + +```elixir +defmodule MyPlugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _opts) do + quote do + if unquote(__MODULE__).__opt_in__?(__MODULE__) do + # do something + end + + unquote(definition) + end + end + + @impl TypedStructor.Plugin + defmacro after_definition(_definition, _opts) do + quote do + if unquote(__MODULE__).__opt_in__?(__MODULE__) do + # do something + end + end + end + + def __opt_in__?(module) when is_atom(module) do + # Opt-in only for schemas under MyApp.Schemas + String.starts_with?(Atom.to_string(module), "MyApp.Schemas") + end +end +``` diff --git a/guides/plugins/type_only_on_ecto_schema.md b/guides/plugins/type_only_on_ecto_schema.md new file mode 100644 index 0000000..f8dca33 --- /dev/null +++ b/guides/plugins/type_only_on_ecto_schema.md @@ -0,0 +1,108 @@ +# Type Only on Ecto Schema + +`Ecto` is a great library for working with both databases and data validation. +However, it has its own way of defining schemas and fields, +which results a struct but without type definitions. +This plugin automatically disables struct creation for `Ecto` schemas. + +## Implementation + +It uses the `c:TypedStructor.Plugin.before_definition/2` callback to determine if the module is an `Ecto` schema +by checking the `@ecto_fields` module attribute. If it is, the `:define_struct` option is +set to `false` to prevent struct creation. + +Here is the plugin(*feel free to copy and paste*): +```elixir +defmodule MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _opts) do + quote do + if Module.has_attribute?(__MODULE__, :ecto_fields) do + Map.update!(unquote(definition), :options, fn opts -> + Keyword.put(opts, :define_struct, false) + end) + else + unquote(definition) + end + end + end +end +``` + +## Usage + +To use this plugin, you can add it to the `typed_structor` block like this: +```elixir +defmodule MyApp.User do + use TypedStructor + use Ecto.Schema + + typed_structor do + plugin MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema + + field :id, integer(), enforce: true + field :name, String.t() + field :age, integer(), enforce: true # There is always a non-nil value + end + + schema "source" do + field :name, :string + field :age, :integer, default: 20 + end +end +``` + +## Registering the plugin globally +```elixir +config :typed_structor, plugins: [MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema] +``` + +Note that the plugin is applied to **all modules** that use `TypedStructor`, +you can opt-out by determining the module name or other conditions. + +Let's change the plugin to only apply to modules from the `MyApp` namespace(*feel free to copy and paste*): + +```elixir +defmodule MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _opts) do + quote do + # Check if the module is from the MyApp namespace + with "MyApp" <- __MODULE__ |> Module.split() |> hd(), + true <- Module.has_attribute?(__MODULE__, :ecto_fields) do + Map.update!(unquote(definition), :options, fn opts -> + Keyword.put(opts, :define_struct, false) + end) + else + _otherwise -> unquote(definition) + end + end + end +end +``` + +Now you can use `typed_structor` without registering the plugin explicitly: + +```diff + defmodule MyApp.User do + use TypedStructor + use Ecto.Schema + + typed_structor do +- plugin MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema + + field :id, integer(), enforce: true + field :name, String.t() + field :age, integer(), enforce: true + end + + schema "source" do + field :name, :string + field :age, :integer, default: 20 + end + end +``` diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 9270560..493667d 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -62,6 +62,13 @@ defmodule TypedStructor do end """ defmacro typed_structor(options \\ [], do: block) when is_list(options) do + register_plugins = + for {plugin, opts} <- get_global_plugins() do + quote do + TypedStructor.plugin(unquote(plugin), unquote(opts)) + end + end + definition = quote do {module, options} = Keyword.pop(unquote(options), :module, __MODULE__) @@ -76,6 +83,9 @@ defmodule TypedStructor do # create a lexical scope try do import TypedStructor, only: [field: 2, field: 3, parameter: 1, plugin: 1, plugin: 2] + + unquote(register_plugins) + unquote(block) fields = Enum.reverse(@__ts_struct_fields_acc__) @@ -133,6 +143,33 @@ defmodule TypedStructor do end end + # get the global plugins from config + defp get_global_plugins do + :typed_structor + |> Application.get_env(:plugins, []) + |> Enum.map(fn + {plugin, opts} when is_atom(plugin) and is_list(opts) -> + {plugin, opts} + + plugin when is_atom(plugin) -> + {plugin, []} + + other -> + raise ArgumentError, + """ + Expected a plugin module or a tuple with a plugin module and its keyword options, + Got: #{inspect(other)} + + Example: + + config :typed_structor, plugins: [ + {MyPlugin, [option: :value]}, + MyAnotherPlugin + ] + """ + end) + end + @doc """ Defines a field in a `typed_structor/2`. You can override the options set by `typed_structor/2` by passing options. @@ -202,7 +239,7 @@ defmodule TypedStructor do `TypedStructor.Plugin`. To use a third-party plugin, please refer directly to its documentation. """ - defmacro plugin(plugin, opts \\ []) do + defmacro plugin(plugin, opts \\ []) when is_list(opts) do quote do require unquote(plugin) diff --git a/mix.exs b/mix.exs index ade0394..862267a 100644 --- a/mix.exs +++ b/mix.exs @@ -7,7 +7,7 @@ defmodule TypedStructor.MixProject do [ app: :typed_structor, description: "TypedStructor is a library for defining structs with types effortlessly.", - version: "0.1.5", + version: "0.2.0", elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -19,10 +19,24 @@ defmodule TypedStructor.MixProject do main: "TypedStructor", source_url: @source_url, extra_section: "Guides", + groups_for_extras: [ + Guides: ~r<(guides/[^\/]+\.md)|(README.md)>, + Plugins: ~r{guides/plugins/[^\/]+\.md} + ], extras: [ - "README.md", + {"CHANGELOG.md", [title: "Changelog"]}, + + # guides + {"README.md", [title: "Introduction"]}, "guides/migrate_from_typed_struct.md", - "CHANGELOG.md" + + # plugins + {"guides/plugins/introduction.md", [title: "Introduction"]}, + "guides/plugins/registering_plugins_globally.md", + "guides/plugins/type_only_on_ecto_schema.md", + "guides/plugins/primary_key_and_timestamps.md", + "guides/plugins/derive_jason.md", + "guides/plugins/derive_enumerable.md" ] ], package: [ diff --git a/test/config_test.exs b/test/config_test.exs new file mode 100644 index 0000000..7a04748 --- /dev/null +++ b/test/config_test.exs @@ -0,0 +1,85 @@ +defmodule ConfigTest do + # disable async for this test for changing the application env + use TypedStructor.TypeCase, async: false + + defmodule Plugin do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro init(opts) do + quote do + @plugin_calls {unquote(__MODULE__), unquote(opts)} + end + end + end + + defmodule PluginWithOpts do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro init(opts) do + quote do + @plugin_calls {unquote(__MODULE__), unquote(opts)} + end + end + end + + test "registers plugins from the config" do + set_plugins_config([Plugin, {PluginWithOpts, [foo: :bar]}]) + + deftmpmodule do + use TypedStructor + + Module.register_attribute(__MODULE__, :plugin_calls, accumulate: true) + + typed_structor do + field :name, String.t() + end + + def plugin_calls, do: @plugin_calls + end + + assert [ + {PluginWithOpts, [foo: :bar]}, + {Plugin, []} + ] === TestModule.plugin_calls() + end + + test "raises if the plugin is not a module" do + set_plugins_config([42]) + + assert_raise ArgumentError, + ~r/Expected a plugin module or a tuple with a plugin module and its keyword options/, + fn -> + test_module do + use TypedStructor + + typed_structor do + field :name, String.t() + end + end + end + end + + test "raises if the options are not a keyword list" do + set_plugins_config([PluginWithOpts, 42]) + + assert_raise ArgumentError, + ~r/Expected a plugin module or a tuple with a plugin module and its keyword options/, + fn -> + test_module do + use TypedStructor + + typed_structor do + field :name, String.t() + end + end + end + end + + defp set_plugins_config(plugins) do + previous_value = Application.get_env(:typed_structor, :plugins) + Application.put_env(:typed_structor, :plugins, plugins) + on_exit(fn -> Application.put_env(:typed_structor, :plugins, previous_value) end) + end +end