From 9a9816eab1c44ccb5cc1fd489b84f345ee97b1b3 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Mon, 1 Jul 2024 09:54:52 +0800 Subject: [PATCH 1/8] refactor: put definition asts into a new lexical scope --- lib/typed_structor.ex | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 493667d..53d0bc8 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -69,7 +69,7 @@ defmodule TypedStructor do end end - definition = + ast = quote do {module, options} = Keyword.pop(unquote(options), :module, __MODULE__) @@ -87,7 +87,12 @@ defmodule TypedStructor do unquote(register_plugins) unquote(block) + after + :ok + end + # create a lexical scope + try do fields = Enum.reverse(@__ts_struct_fields_acc__) parameters = Enum.reverse(@__ts_struct_parameters_acc__) @@ -106,18 +111,13 @@ defmodule TypedStructor do @__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) + TypedStructor.__struct_ast__() + TypedStructor.__type_ast__() + TypedStructor.__reflection_ast__() # create a lexical scope try do From 644cccb6473f5378e5a61e6c38685bfe647386a6 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Mon, 1 Jul 2024 09:56:27 +0800 Subject: [PATCH 2/8] docs: do format --- lib/typed_structor/plugin.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/typed_structor/plugin.ex b/lib/typed_structor/plugin.ex index 11f7a25..dc21459 100644 --- a/lib/typed_structor/plugin.ex +++ b/lib/typed_structor/plugin.ex @@ -54,7 +54,8 @@ defmodule TypedStructor.Plugin do @type t() :: %MyApp.User{ __meta__: Ecto.Schema.Metadata.t(), age: integer() | nil, - id: integer(), name: String.t(), + id: integer(), + name: String.t(), password: String.t() | nil, posts: [MyApp.Post.t()] | nil } @@ -82,7 +83,8 @@ defmodule TypedStructor.Plugin do related_key: :user_id, on_cast: nil, queryable: MyApp.Post, - on_delete: :nothing, on_replace: :raise, + on_delete: :nothing, + on_replace: :raise, where: [], unique: true, defaults: [], From 9c6f3406009565b1e7ccab77d7ce24a7ee3984e6 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Tue, 2 Jul 2024 23:19:37 +0800 Subject: [PATCH 3/8] fix: macro capture will capture import context --- lib/typed_structor.ex | 239 ++++++++++++++++------------------- lib/typed_structor/plugin.ex | 6 +- 2 files changed, 115 insertions(+), 130 deletions(-) diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 53d0bc8..68fac4c 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -62,89 +62,79 @@ 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 - - ast = - 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) + case Keyword.pop(options, :module) do + {nil, options} -> + __typed_structor__(__CALLER__.module, options, block) - @__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(register_plugins) + {module, options} -> + quote do + defmodule unquote(module) do + use TypedStructor - unquote(block) - after - :ok + typed_structor unquote(options) do + unquote(block) + end + end end + end + end - # create a lexical scope - try do - 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__) + 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) - @__ts_struct_plugins__ Enum.reverse(@__ts_struct_plugins_acc__) - Module.delete_attribute(__MODULE__, :__ts_struct_plugins_acc__) + quote do + @__ts_options__ unquote(options) - definition = - TypedStructor.__call_plugins_before_definitions__(%TypedStructor.Definition{ - options: options, - fields: fields, - parameters: parameters - }) + # create a lexical scope + try do + import TypedStructor, only: [field: 2, field: 3, parameter: 1, plugin: 1, plugin: 2] - @__ts_definition__ definition - @__ts_current_module__ {module, definition.options} - after - :ok - end + unquote(register_global_plugins()) - TypedStructor.__struct_ast__() - TypedStructor.__type_ast__() - TypedStructor.__reflection_ast__() - - # 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 + unquote(block) + after + :ok end - case Keyword.fetch(options, :module) do - {:ok, module} -> - quote do - defmodule unquote(module) do - unquote(ast) - end - end + # create a lexical scope + try do + definition = + TypedStructor.__call_plugins_before_definitions__(%TypedStructor.Definition{ + options: @__ts_options__, + fields: Enum.reverse(@__ts_struct_fields__), + parameters: Enum.reverse(@__ts_struct_parameters__) + }) + + @__ts_definition__ definition + @__ts_options__ definition.options + after + :ok + end - :error -> - ast + TypedStructor.__struct_ast__() + TypedStructor.__type_ast__() + TypedStructor.__reflection_ast__() + + # create a lexical scope + try do + TypedStructor.__call_plugins_after_definitions__() + after + # cleanup + Module.delete_attribute(__MODULE__, :__ts_options__) + Module.delete_attribute(__MODULE__, :__ts_struct_fields__) + Module.delete_attribute(__MODULE__, :__ts_struct_parameters__) + Module.delete_attribute(__MODULE__, :__ts_struct_plugins__) + Module.delete_attribute(__MODULE__, :__ts_definition__) + end end end - # get the global plugins from config - defp get_global_plugins do + # register global plugins + defp register_global_plugins do :typed_structor |> Application.get_env(:plugins, []) |> Enum.map(fn @@ -168,6 +158,11 @@ defmodule TypedStructor do ] """ end) + |> Enum.map(fn {plugin, opts} -> + quote do + plugin unquote(plugin), unquote(opts) + end + end) end @doc """ @@ -198,9 +193,7 @@ defmodule TypedStructor 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)) + @__ts_struct_fields__ Keyword.merge(@__ts_options__, unquote(options)) end end @@ -216,7 +209,7 @@ defmodule TypedStructor do """ defmacro parameter(name) when is_atom(name) do quote do - @__ts_struct_parameters_acc__ unquote(name) + @__ts_struct_parameters__ unquote(name) end end @@ -240,22 +233,12 @@ defmodule TypedStructor do its documentation. """ defmacro plugin(plugin, opts \\ []) when is_list(opts) do + Module.put_attribute(__CALLER__.module, :__ts_struct_plugins__, {plugin, opts}) + 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 @@ -280,9 +263,7 @@ defmodule TypedStructor do end quote do - {_module, options} = @__ts_current_module__ - - if Keyword.get(options, :define_struct, true) do + if Keyword.get(@__ts_options__, :define_struct, true) do unquote(ast) end end @@ -291,8 +272,6 @@ defmodule TypedStructor do @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) @@ -305,23 +284,23 @@ defmodule TypedStructor do end end) - type_name = Keyword.get(options, :type_name, :t) + type_name = Keyword.get(@__ts_options__, :type_name, :t) parameters = Enum.map(@__ts_definition__.parameters, &Macro.var(&1, __MODULE__)) - case Keyword.get(options, :type_kind, :type) do + case Keyword.get(@__ts_options__, :type_kind, :type) do :type -> - @type unquote(type_name)(unquote_splicing(parameters)) :: %unquote(module){ + @type unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{ unquote_splicing(fields) } :opaque -> - @opaque unquote(type_name)(unquote_splicing(parameters)) :: %unquote(module){ + @opaque unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{ unquote_splicing(fields) } :typep -> - @typep unquote(type_name)(unquote_splicing(parameters)) :: %unquote(module){ + @typep unquote(type_name)(unquote_splicing(parameters)) :: %__MODULE__{ unquote_splicing(fields) } end @@ -353,45 +332,51 @@ defmodule TypedStructor do end end + @doc false 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 + plugins = Module.get_attribute(__CALLER__.module, :__ts_struct_plugins__) + + Enum.reduce(plugins, definition, fn {plugin, opts}, acc -> + quote do + require unquote(plugin) + + result = unquote(plugin).before_definition(unquote(acc), unquote(opts)) + + result + |> List.wrap() + |> Enum.filter(&is_struct(&1, Definition)) + |> case do + [definition] -> + definition + + _otherwise -> + raise """ + The plugin call to `#{inspect(unquote(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) end + @doc false 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 - ) + plugins = Module.get_attribute(__CALLER__.module, :__ts_struct_plugins__) + + for {plugin, opts} <- plugins do + quote do + require unquote(plugin) + + unquote(plugin).after_definition( + @__ts_definition__, + unquote(Macro.escape(opts)) + ) + end end end end diff --git a/lib/typed_structor/plugin.ex b/lib/typed_structor/plugin.ex index dc21459..84d9c20 100644 --- a/lib/typed_structor/plugin.ex +++ b/lib/typed_structor/plugin.ex @@ -197,15 +197,15 @@ defmodule TypedStructor.Plugin do {:has_many, module} -> module = Macro.expand(module, __ENV__) - Ecto.Schema.has_many(name, module, options) + has_many name, module, options {:belongs_to, module} -> module = Macro.expand(module, __ENV__) - Ecto.Schema.belongs_to(name, module, options) + belongs_to name, module, options _ -> - Ecto.Schema.field(name, ecto_type, options) + field name, ecto_type, options end end end From c191e788b09fa965a39b7ead376ffa48bc095879 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Wed, 3 Jul 2024 22:19:18 +0800 Subject: [PATCH 4/8] test: add guides tests --- guides/plugins/derive_enumerable.md | 10 +- guides/plugins/derive_jason.md | 8 +- guides/plugins/primary_key_and_timestamps.md | 6 +- guides/plugins/type_only_on_ecto_schema.md | 10 +- mix.exs | 2 + test/guides/plugins/enumerable_test.exs | 11 +++ test/guides/plugins/jason_test.exs | 9 ++ .../primary_key_and_timestamps_test.exs | 21 +++++ .../plugins/type_only_on_ecto_schema_test.exs | 19 ++++ test/support/guide_case.ex | 93 +++++++++++++++++++ 10 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 test/guides/plugins/enumerable_test.exs create mode 100644 test/guides/plugins/jason_test.exs create mode 100644 test/guides/plugins/primary_key_and_timestamps_test.exs create mode 100644 test/guides/plugins/type_only_on_ecto_schema_test.exs create mode 100644 test/support/guide_case.ex diff --git a/guides/plugins/derive_enumerable.md b/guides/plugins/derive_enumerable.md index 20972e8..f0591fb 100644 --- a/guides/plugins/derive_enumerable.md +++ b/guides/plugins/derive_enumerable.md @@ -8,7 +8,7 @@ Let's start! ## Implementation ```elixir -defmodule MyPlugin do +defmodule Guides.Plugins.Enumerable do use TypedStructor.Plugin @impl TypedStructor.Plugin @@ -40,11 +40,11 @@ end defmodule User do use TypedStructor - typed_structor do - plugin MyPlugin + typed_structor enforce: true do + plugin Guides.Plugins.Enumerable - field :name, String.t(), enforce: true - field :age, integer(), enforce: true + field :name, String.t() + field :age, Integer.t() end end ``` diff --git a/guides/plugins/derive_jason.md b/guides/plugins/derive_jason.md index b2c4b3c..6bd2573 100644 --- a/guides/plugins/derive_jason.md +++ b/guides/plugins/derive_jason.md @@ -5,7 +5,7 @@ generate the `Jason.Encoder` implementation for the struct. ## Implementation ```elixir -defmodule MyPlugin do +defmodule Guides.Plugins.Jason do use TypedStructor.Plugin @impl TypedStructor.Plugin @@ -29,10 +29,10 @@ end defmodule User do use TypedStructor - typed_structor do - plugin MyPlugin + typed_structor enforce: true do + plugin Guides.Plugins.Jason - field :name, String.t(), enforce: true + field :name, String.t() end end ``` diff --git a/guides/plugins/primary_key_and_timestamps.md b/guides/plugins/primary_key_and_timestamps.md index 55b64bd..a258d18 100644 --- a/guides/plugins/primary_key_and_timestamps.md +++ b/guides/plugins/primary_key_and_timestamps.md @@ -7,7 +7,7 @@ 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 +defmodule Guides.Plugins.PrimaryKeyAndTimestamps do use TypedStructor.Plugin @impl TypedStructor.Plugin @@ -33,14 +33,14 @@ end ## Usage ```elixir -defmodule MyApp.User do +defmodule 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 + plugin Guides.Plugins.PrimaryKeyAndTimestamps field :name, String.t() field :age, integer(), enforce: true # There is always a non-nil value diff --git a/guides/plugins/type_only_on_ecto_schema.md b/guides/plugins/type_only_on_ecto_schema.md index f8dca33..5951fba 100644 --- a/guides/plugins/type_only_on_ecto_schema.md +++ b/guides/plugins/type_only_on_ecto_schema.md @@ -13,7 +13,7 @@ set to `false` to prevent struct creation. Here is the plugin(*feel free to copy and paste*): ```elixir -defmodule MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema do +defmodule Guides.Plugins.TypeOnlyOnEctoSchema do use TypedStructor.Plugin @impl TypedStructor.Plugin @@ -40,7 +40,7 @@ defmodule MyApp.User do use Ecto.Schema typed_structor do - plugin MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema + plugin Guides.Plugins.TypeOnlyOnEctoSchema field :id, integer(), enforce: true field :name, String.t() @@ -56,7 +56,7 @@ end ## Registering the plugin globally ```elixir -config :typed_structor, plugins: [MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema] +config :typed_structor, plugins: [Guides.Plugins.TypeOnlyOnEctoSchema] ``` Note that the plugin is applied to **all modules** that use `TypedStructor`, @@ -65,7 +65,7 @@ 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 +defmodule Guides.Plugins.TypeOnlyOnEctoSchema do use TypedStructor.Plugin @impl TypedStructor.Plugin @@ -93,7 +93,7 @@ Now you can use `typed_structor` without registering the plugin explicitly: use Ecto.Schema typed_structor do -- plugin MyApp.TypedStructor.Plugins.TypeOnlyOnEctoSchema +- plugin Guides.Plugins.TypeOnlyOnEctoSchema field :id, integer(), enforce: true field :name, String.t() diff --git a/mix.exs b/mix.exs index 862267a..aab115f 100644 --- a/mix.exs +++ b/mix.exs @@ -11,6 +11,7 @@ defmodule TypedStructor.MixProject do elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, + consolidate_protocols: Mix.env() != :test, deps: deps(), name: "TypedStructor", source: @source_url, @@ -61,6 +62,7 @@ defmodule TypedStructor.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:ecto, "~> 3.0", only: [:dev, :test], optional: true}, + {:jason, "~> 1.4", only: [:dev, :test], optional: true}, {:makeup_diff, "~> 0.1", only: [:test, :dev], runtime: false} ] end diff --git a/test/guides/plugins/enumerable_test.exs b/test/guides/plugins/enumerable_test.exs new file mode 100644 index 0000000..e8bec3b --- /dev/null +++ b/test/guides/plugins/enumerable_test.exs @@ -0,0 +1,11 @@ +defmodule Guides.Plugins.EnumerableTest do + use TypedStructor.GuideCase, + async: true, + guide: "derive_enumerable.md" + + test "works" do + user = %User{name: "Phil", age: 20} + assert [:name, :age] === Enum.map(user, fn {key, _value} -> key end) + assert [name: "Phil", age: 20] === Enum.to_list(user) + end +end diff --git a/test/guides/plugins/jason_test.exs b/test/guides/plugins/jason_test.exs new file mode 100644 index 0000000..b15844b --- /dev/null +++ b/test/guides/plugins/jason_test.exs @@ -0,0 +1,9 @@ +defmodule Guides.Plugins.JasonTest do + use TypedStructor.GuideCase, + async: true, + guide: "derive_jason.md" + + test "works" do + assert {:ok, "{\"name\":\"Phil\"}"} === Jason.encode(%User{name: "Phil"}) + end +end diff --git a/test/guides/plugins/primary_key_and_timestamps_test.exs b/test/guides/plugins/primary_key_and_timestamps_test.exs new file mode 100644 index 0000000..e4901cc --- /dev/null +++ b/test/guides/plugins/primary_key_and_timestamps_test.exs @@ -0,0 +1,21 @@ +defmodule Guides.Plugins.PrimaryKeyAndTimestampsTest do + use TypedStructor.GuideCase, + async: true, + guide: "primary_key_and_timestamps.md" + + test "works", ctx do + assert """ + @type t() :: %Guides.Plugins.PrimaryKeyAndTimestampsTest.User{ + __meta__: term(), + age: integer(), + id: integer(), + inserted_at: NaiveDateTime.t(), + name: String.t() | nil, + updated_at: NaiveDateTime.t() + } + """ === types(ctx.registered.bytecode) + + user = %User{name: "Phil"} + assert 20 === user.age + end +end diff --git a/test/guides/plugins/type_only_on_ecto_schema_test.exs b/test/guides/plugins/type_only_on_ecto_schema_test.exs new file mode 100644 index 0000000..556bfe0 --- /dev/null +++ b/test/guides/plugins/type_only_on_ecto_schema_test.exs @@ -0,0 +1,19 @@ +defmodule Guides.Plugins.TypeOnlyOnEctoSchema do + use TypedStructor.GuideCase, + async: true, + guide: "type_only_on_ecto_schema.md" + + test "works", ctx do + assert """ + @type t() :: %Guides.Plugins.TypeOnlyOnEctoSchema.MyApp.User{ + __meta__: term(), + age: integer(), + id: integer(), + name: String.t() | nil + } + """ === types(ctx.registered.bytecode) + + user = %MyApp.User{name: "Phil"} + assert 20 === user.age + end +end diff --git a/test/support/guide_case.ex b/test/support/guide_case.ex new file mode 100644 index 0000000..d9f7e1e --- /dev/null +++ b/test/support/guide_case.ex @@ -0,0 +1,93 @@ +defmodule TypedStructor.GuideCase do + @moduledoc false + + use ExUnit.CaseTemplate + + using opts do + guide = Keyword.fetch!(opts, :guide) + + file = Path.expand([__DIR__, "../../../", "guides/plugins/", guide]) + + ast = Code.string_to_quoted!(extract_code(file)) + + quote do + Code.compiler_options(debug_info: true) + + {:module, _module_name, bytecode, _submodule} = unquote(ast) + + ExUnit.Case.register_module_attribute(__MODULE__, :bytecode) + @bytecode bytecode + + import unquote(__MODULE__), only: [types: 1] + end + end + + def types(bytecode) when is_binary(bytecode) do + TypedStructor.TypeCase.types(bytecode) <> "\n" + end + + defp extract_code(file) do + content = + File.read!(file) + + content + |> String.split("\n") + |> Enum.with_index(1) + |> extract_code(%{in_code_block?: false, codes: []}) + |> case do + %{codes: [implementation, usage | _resut]} -> + """ + #{implementation |> Enum.reverse() |> Enum.join("\n")} + #{usage |> Enum.reverse() |> Enum.join("\n")} + """ + + ctx -> + raise ArgumentError, + """ + Cannot find implementation and usage in file #{inspect(file)} + context: #{inspect(ctx)} + #{content} + """ + end + end + + @start "```elixir" + @stop "```" + + defp extract_code([], %{in_code_block?: false} = ctx) do + %{ctx | codes: Enum.reverse(ctx.codes)} + end + + defp extract_code([{@start, _index} | rest], %{in_code_block?: false} = ctx) do + extract_code(rest, %{ctx | in_code_block?: true, codes: [[] | ctx.codes]}) + end + + defp extract_code([{@stop, _index} | rest], %{in_code_block?: true} = ctx) do + extract_code(rest, %{ctx | in_code_block?: false}) + end + + defp extract_code([{text, _index} | rest], %{in_code_block?: true} = ctx) do + [current | codes] = ctx.codes + extract_code(rest, %{ctx | codes: [[text | current] | codes]}) + end + + defp extract_code([_text | rest], %{in_code_block?: false} = ctx) do + extract_code(rest, ctx) + end + + defp extract_code([{text, index} | _rest], ctx) do + raise ArgumentError, + """ + Unexpected text at line #{index}: #{inspect(text)} + context: #{inspect(ctx)} + """ + end + + defp extract_code([], ctx) do + raise ArgumentError, + """ + Code block not closed + context: #{inspect(ctx)} + """ + end +end From 204cb0aeab5a7ca8d5a15d7e016243d66af6e595 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Wed, 3 Jul 2024 22:20:10 +0800 Subject: [PATCH 5/8] refactor!: remove accessible plugin, add it to guides --- .../plugins/accessible.md | 53 ++++++++++++++++--- guides/plugins/introduction.md | 2 +- mix.exs | 1 + test/guides/plugins/accessible_test.exs | 30 +++++++++++ .../plugins/accessible_test.exs | 43 --------------- 5 files changed, 78 insertions(+), 51 deletions(-) rename lib/typed_structor/plugins/accessible.ex => guides/plugins/accessible.md (56%) create mode 100644 test/guides/plugins/accessible_test.exs delete mode 100644 test/typed_structor/plugins/accessible_test.exs diff --git a/lib/typed_structor/plugins/accessible.ex b/guides/plugins/accessible.md similarity index 56% rename from lib/typed_structor/plugins/accessible.ex rename to guides/plugins/accessible.md index 6fbed2b..008431c 100644 --- a/lib/typed_structor/plugins/accessible.ex +++ b/guides/plugins/accessible.md @@ -1,4 +1,21 @@ -defmodule TypedStructor.Plugins.Accessible do +# Implement `Access` behavior + +Sometimes, you may want to use the `get_in/2` and `update_in/3` functions +with your structs. This guide demonstrates how to effortlessly implement +the `Access` behavior for your structs using a plugin. + +## Implementation + +> #### 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. + +```elixir +defmodule Guides.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` @@ -14,13 +31,11 @@ defmodule TypedStructor.Plugins.Accessible do ## Usage - ```elixir - typed_structor do - plugin TypedStructor.Plugins.Accessible + typed_structor do + plugin TypedStructor.Plugins.Accessible - # fields - end - ``` + # fields + end """ use TypedStructor.Plugin @@ -53,3 +68,27 @@ defmodule TypedStructor.Plugins.Accessible do end end end +``` + +## Usage +```elixir +defmodule User do + use TypedStructor + + typed_structor do + plugin Guides.Plugins.Accessible + + field :name, String.t() + field :age, integer() + end +end +``` + +```elixir +iex> user = %User{name: "Phil", age: 20} +%User{name: "Phil", age: 20} +iex> get_in(user, [:name]) +"Phil" +iex> put_in(user, [:name], "phil") +%User{name: "phil", age: 20} +``` diff --git a/guides/plugins/introduction.md b/guides/plugins/introduction.md index 7c78178..7d21cc0 100644 --- a/guides/plugins/introduction.md +++ b/guides/plugins/introduction.md @@ -12,7 +12,7 @@ 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` +- [Implement `Access` behavior](./accessible.md) - [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) diff --git a/mix.exs b/mix.exs index aab115f..ed23023 100644 --- a/mix.exs +++ b/mix.exs @@ -34,6 +34,7 @@ defmodule TypedStructor.MixProject do # plugins {"guides/plugins/introduction.md", [title: "Introduction"]}, "guides/plugins/registering_plugins_globally.md", + "guides/plugins/accessible.md", "guides/plugins/type_only_on_ecto_schema.md", "guides/plugins/primary_key_and_timestamps.md", "guides/plugins/derive_jason.md", diff --git a/test/guides/plugins/accessible_test.exs b/test/guides/plugins/accessible_test.exs new file mode 100644 index 0000000..ed638c8 --- /dev/null +++ b/test/guides/plugins/accessible_test.exs @@ -0,0 +1,30 @@ +defmodule Guides.Plugins.AccessibleTest do + use TypedStructor.GuideCase, + async: true, + guide: "accessible.md" + + test "implements Access" do + user = %User{name: "Phil", age: 20} + + assert "Phil" === get_in(user, [:name]) + assert %User{name: "phil", age: 20} === put_in(user, [:name], "phil") + + assert_raise ArgumentError, ~r/Cannot update `:__struct__` key/, fn -> + put_in(user, [:__struct__], "phil") + end + + assert %{name: "phil"} = update_in(user, [:name], fn "Phil" -> "phil" end) + + assert_raise ArgumentError, ~r/Cannot update `:__struct__` key/, fn -> + update_in(user, [:__struct__], fn _ -> nil end) + end + + assert_raise ArgumentError, ~r/Cannot pop `:__struct__` key/, fn -> + pop_in(user, [:__struct__]) + end + + assert_raise ArgumentError, ~r/Cannot pop `:name` key/, fn -> + pop_in(user, [:name]) + end + end +end diff --git a/test/typed_structor/plugins/accessible_test.exs b/test/typed_structor/plugins/accessible_test.exs deleted file mode 100644 index d426807..0000000 --- a/test/typed_structor/plugins/accessible_test.exs +++ /dev/null @@ -1,43 +0,0 @@ -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 From af05ae0370142c22683006b3f61682f77c6728c0 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Wed, 3 Jul 2024 22:42:44 +0800 Subject: [PATCH 6/8] doc: add the reflection guide --- guides/plugins/introduction.md | 1 + guides/plugins/reflection.md | 76 +++++++++++++++++++++++++ lib/typed_structor.ex | 26 --------- mix.exs | 1 + test/guides/plugins/reflection_test.exs | 35 ++++++++++++ test/reflection_test.exs | 53 ----------------- 6 files changed, 113 insertions(+), 79 deletions(-) create mode 100644 guides/plugins/reflection.md create mode 100644 test/guides/plugins/reflection_test.exs delete mode 100644 test/reflection_test.exs diff --git a/guides/plugins/introduction.md b/guides/plugins/introduction.md index 7d21cc0..127d437 100644 --- a/guides/plugins/introduction.md +++ b/guides/plugins/introduction.md @@ -13,6 +13,7 @@ We provide some example plugins that you can use as a reference, or copy-paste. **Plugin examples:** - [Registering plugins globally](./registering_plugins_globally.md) - [Implement `Access` behavior](./accessible.md) +- [Implement reflection functions](./reflection.md) - [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) diff --git a/guides/plugins/reflection.md b/guides/plugins/reflection.md new file mode 100644 index 0000000..a51d30a --- /dev/null +++ b/guides/plugins/reflection.md @@ -0,0 +1,76 @@ +# Implement reflection functions + +Define a plugin that generates reflection functions that +can be used to access the fields and parameters of a struct. + +## Implement + +```elixir +defmodule Guides.Plugins.Reflection do + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro after_definition(definition, _opts) do + quote bind_quoted: [definition: definition] do + fields = Enum.map(definition.fields, &Keyword.fetch!(&1, :name)) + + enforced_fields = + 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: unquote(definition.parameters) + def __typed_structor__(:enforced_fields), do: unquote(enforced_fields) + + for field <- 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 +end +``` + +## Usage +```elixir +defmodule User do + use TypedStructor + + typed_structor do + plugin Guides.Plugins.Reflection + + parameter :age + + field :name, String.t(), enforce: true + field :age, age, default: 20 + end +end + +defmodule MyApp do + use TypedStructor + + typed_structor module: User, enforce: true do + plugin Guides.Plugins.Reflection + + field :name, String.t() + field :age, integer() + end +end +``` + +```elixir +iex> User.__typed_structor__(:fields) +[:name, :age] +iex> User.__typed_structor__(:parameters) +[:age] + +iex> MyApp.User.__typed_structor__(:enforced_fields) +[:name, :age] +iex> Macro.to_string(User.__typed_structor__(:type, :age)) +"age" +``` diff --git a/lib/typed_structor.ex b/lib/typed_structor.ex index 68fac4c..c2959a4 100644 --- a/lib/typed_structor.ex +++ b/lib/typed_structor.ex @@ -117,7 +117,6 @@ defmodule TypedStructor do TypedStructor.__struct_ast__() TypedStructor.__type_ast__() - TypedStructor.__reflection_ast__() # create a lexical scope try do @@ -307,31 +306,6 @@ defmodule TypedStructor do 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 - @doc false defmacro __call_plugins_before_definitions__(definition) do alias TypedStructor.Definition diff --git a/mix.exs b/mix.exs index ed23023..c5002dd 100644 --- a/mix.exs +++ b/mix.exs @@ -35,6 +35,7 @@ defmodule TypedStructor.MixProject do {"guides/plugins/introduction.md", [title: "Introduction"]}, "guides/plugins/registering_plugins_globally.md", "guides/plugins/accessible.md", + "guides/plugins/reflection.md", "guides/plugins/type_only_on_ecto_schema.md", "guides/plugins/primary_key_and_timestamps.md", "guides/plugins/derive_jason.md", diff --git a/test/guides/plugins/reflection_test.exs b/test/guides/plugins/reflection_test.exs new file mode 100644 index 0000000..1292b80 --- /dev/null +++ b/test/guides/plugins/reflection_test.exs @@ -0,0 +1,35 @@ +defmodule Guides.Plugins.ReflectionTest do + use TypedStructor.GuideCase, + async: true, + guide: "reflection.md" + + test "generates reflection functions" do + assert [:name, :age] === User.__typed_structor__(:fields) + assert [:name, :age] === MyApp.User.__typed_structor__(:fields) + + assert [:age] === User.__typed_structor__(:parameters) + assert [] === MyApp.User.__typed_structor__(:parameters) + + assert [:name] === User.__typed_structor__(:enforced_fields) + assert [:name, :age] === MyApp.User.__typed_structor__(:enforced_fields) + + assert "String.t()" === Macro.to_string(User.__typed_structor__(:type, :name)) + assert "age" === Macro.to_string(User.__typed_structor__(:type, :age)) + + assert "String.t()" === Macro.to_string(MyApp.User.__typed_structor__(:type, :name)) + assert "integer()" === Macro.to_string(MyApp.User.__typed_structor__(:type, :age)) + + 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] = + MyApp.User.__typed_structor__(:field, :age) + + assert "integer()" === Macro.to_string(type) + + assert [enforce: true, name: :name, type: type] = User.__typed_structor__(:field, :name) + assert "String.t()" === Macro.to_string(type) + assert [default: 20, name: :age, type: type] = User.__typed_structor__(:field, :age) + assert "age" === Macro.to_string(type) + end +end diff --git a/test/reflection_test.exs b/test/reflection_test.exs deleted file mode 100644 index 18662b4..0000000 --- a/test/reflection_test.exs +++ /dev/null @@ -1,53 +0,0 @@ -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, default: 20 - 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 [default: 20, name: :age, type: type] = Struct.__typed_structor__(:field, :age) - assert "age" === Macro.to_string(type) - end -end From 2080f40448e50f09e7ac291ed7e230dfee4f4612 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Wed, 3 Jul 2024 22:52:35 +0800 Subject: [PATCH 7/8] doc: add plugin guides link to README --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90276dc..fe2e79f 100644 --- a/README.md +++ b/README.md @@ -195,13 +195,13 @@ end `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. +Here is a example of `Guides.Plugins.Accessible` plugin to define `Access` behavior for the struct. ```elixir defmodule User do use TypedStructor typed_structor do - plugin TypedStructor.Plugins.Accessible + plugin Guides.Plugins.Accessible field :id, pos_integer() field :name, String.t() @@ -212,3 +212,9 @@ end user = %User{id: 1, name: "Phil", age: 20} get_in(user, [:name]) # => "Phil" ``` + +> #### Plugins guides {: .tip} +> +> Here are some [Plugin Guides](guides/plugins/introduction.md) +> for creating your own plugins. Please check them out +> and feel free to copy-paste the code. From c2b6c88f2cc15ca1aa54e14967fd3e8675651979 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Wed, 3 Jul 2024 22:54:15 +0800 Subject: [PATCH 8/8] chore: bump to v0.3.0 --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 2 +- mix.exs | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 832e020..ff607ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.3.0](https://github.com/elixir-typed-structor/typed_structor/compare/v0.2.0...v0.3.0) (2024-07-03) + + +### ⚠ BREAKING CHANGES + +* remove accessible plugin, add it to guides + +### Bug Fixes + +* macro capture will capture import context ([9c6f340](https://github.com/elixir-typed-structor/typed_structor/commit/9c6f3406009565b1e7ccab77d7ce24a7ee3984e6)) + + +### Code Refactoring + +* remove accessible plugin, add it to guides ([204cb0a](https://github.com/elixir-typed-structor/typed_structor/commit/204cb0aeab5a7ca8d5a15d7e016243d66af6e595)) + ## [0.2.0](https://github.com/elixir-typed-structor/typed_structor/compare/v0.1.5...v0.2.0) (2024-06-30) diff --git a/README.md b/README.md index fe2e79f..ccaa893 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Add `:typed_structor` to the list of dependencies in `mix.exs`: ```elixir def deps do [ - {:typed_structor, "~> 0.1"} + {:typed_structor, "~> 0.3"} ] end ``` diff --git a/mix.exs b/mix.exs index c5002dd..46d3b4d 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.2.0", + version: "0.3.0", elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod,