diff --git a/lib/ash/domain/igniter.ex b/lib/ash/domain/igniter.ex index 796192f71..9d3456937 100644 --- a/lib/ash/domain/igniter.ex +++ b/lib/ash/domain/igniter.ex @@ -1,26 +1,75 @@ -defmodule Ash.Domain.Igniter do - @moduledoc "Codemods for working with Ash.Domain modules" +if Code.ensure_loaded?(Igniter) do + defmodule Ash.Domain.Igniter do + @moduledoc "Codemods for working with Ash.Domain modules" - @doc "List all domain modules found in the project" - def list_domains(igniter) do - Igniter.Project.Module.find_all_matching_modules(igniter, fn _mod, zipper -> - zipper - |> Igniter.Code.Module.move_to_use(Ash.Domain) - |> case do - {:ok, _} -> - true + @doc "List all domain modules found in the project" + def list_domains(igniter) do + Igniter.Project.Module.find_all_matching_modules(igniter, fn _mod, zipper -> + zipper + |> Igniter.Code.Module.move_to_use(Ash.Domain) + |> case do + {:ok, _} -> + true - _ -> - false - end - end) - end + _ -> + false + end + end) + end - @doc "Adds a resource reference to a domain's `resources` block" - def add_resource_reference(igniter, domain, resource) do - {igniter, domains} = Ash.Domain.Igniter.list_domains(igniter) + @doc "Adds a resource reference to a domain's `resources` block" + def add_resource_reference(igniter, domain, resource) do + {igniter, domains} = Ash.Domain.Igniter.list_domains(igniter) - if domain in domains do + if domain in domains do + Igniter.Project.Module.find_and_update_module!(igniter, domain, fn zipper -> + case Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :resources, + 1 + ) do + :error -> + code = + """ + resources do + resource #{inspect(resource)} + end + """ + + {:ok, Igniter.Code.Common.add_code(zipper, code)} + + {:ok, zipper} -> + with {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), + :error <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :resource, + 1, + fn call -> + Igniter.Code.Function.argument_matches_predicate?( + call, + 0, + &Igniter.Code.Common.nodes_equal?(&1, resource) + ) + end + ) do + {:ok, Igniter.Code.Common.add_code(zipper, "resource #{inspect(resource)}")} + else + _ -> + {:ok, zipper} + end + end + end) + else + igniter + |> Igniter.add_warning( + "Domain #{domain} was not an `Ash.Domain`, so could not add `#{inspect(resource)}` to its resource list." + ) + end + end + + @doc "Removes a resource reference from a domain's `resources` block" + def remove_resource_reference(igniter, domain, resource) do Igniter.Project.Module.find_and_update_module!(igniter, domain, fn zipper -> case Igniter.Code.Function.move_to_function_call_in_current_scope( zipper, @@ -28,18 +77,11 @@ defmodule Ash.Domain.Igniter do 1 ) do :error -> - code = - """ - resources do - resource #{inspect(resource)} - end - """ - - {:ok, Igniter.Code.Common.add_code(zipper, code)} + zipper {:ok, zipper} -> with {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), - :error <- + {:ok, zipper} <- Igniter.Code.Function.move_to_function_call_in_current_scope( zipper, :resource, @@ -52,53 +94,13 @@ defmodule Ash.Domain.Igniter do ) end ) do - {:ok, Igniter.Code.Common.add_code(zipper, "resource #{inspect(resource)}")} + {:ok, Sourceror.Zipper.remove(zipper)} else _ -> {:ok, zipper} end end end) - else - igniter - |> Igniter.add_warning( - "Domain #{domain} was not an `Ash.Domain`, so could not add `#{inspect(resource)}` to its resource list." - ) end end - - @doc "Removes a resource reference from a domain's `resources` block" - def remove_resource_reference(igniter, domain, resource) do - Igniter.Project.Module.find_and_update_module!(igniter, domain, fn zipper -> - case Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :resources, - 1 - ) do - :error -> - zipper - - {:ok, zipper} -> - with {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), - {:ok, zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :resource, - 1, - fn call -> - Igniter.Code.Function.argument_matches_predicate?( - call, - 0, - &Igniter.Code.Common.nodes_equal?(&1, resource) - ) - end - ) do - {:ok, Sourceror.Zipper.remove(zipper)} - else - _ -> - {:ok, zipper} - end - end - end) - end end diff --git a/lib/ash/igniter.ex b/lib/ash/igniter.ex index 6b17856ee..d40a6282a 100644 --- a/lib/ash/igniter.ex +++ b/lib/ash/igniter.ex @@ -1,29 +1,31 @@ -defmodule Ash.Igniter do - @moduledoc "Codemods and utilities for working with Ash & Igniter" +if Code.ensure_loaded?(Igniter) do + defmodule Ash.Igniter do + @moduledoc "Codemods and utilities for working with Ash & Igniter" - @doc "Adds a codegen task, or updates the name to be `_and_name`" - def codegen(igniter, name) do - has_codegen? = - Enum.any?(igniter.tasks, fn - {"ash.codegen", _args} -> - true + @doc "Adds a codegen task, or updates the name to be `_and_name`" + def codegen(igniter, name) do + has_codegen? = + Enum.any?(igniter.tasks, fn + {"ash.codegen", _args} -> + true - _ -> - false - end) + _ -> + false + end) - if has_codegen? do - Map.update!(igniter, :tasks, fn tasks -> - Enum.map(tasks, fn - {"ash.codegen", [old_name | rest]} -> - {"ash.codegen", [old_name <> "_and_#{name}" | rest]} + if has_codegen? do + Map.update!(igniter, :tasks, fn tasks -> + Enum.map(tasks, fn + {"ash.codegen", [old_name | rest]} -> + {"ash.codegen", [old_name <> "_and_#{name}" | rest]} - task -> - task + task -> + task + end) end) - end) - else - Igniter.add_task(igniter, "ash.codegen", [name]) + else + Igniter.add_task(igniter, "ash.codegen", [name]) + end end end end diff --git a/lib/ash/resource/igniter.ex b/lib/ash/resource/igniter.ex index e869058b5..e525f2dac 100644 --- a/lib/ash/resource/igniter.ex +++ b/lib/ash/resource/igniter.ex @@ -1,187 +1,216 @@ -defmodule Ash.Resource.Igniter do - @moduledoc """ - Codemods for working with Ash.Resource modules - - ## Important Details - - This interrogates *the source code* of a resource, not its ultimate compiled state. - What this means, is that things like `defines_attribute` will not return `true` if - the attribute is added by an extension. Only if it appears literally in the source code - of the resource or one of its `Spark.Dsl.Fragment`s. - """ - - @doc "List all resource modules found in the project" - def list_resources(igniter) do - Igniter.Project.Module.find_all_matching_modules(igniter, fn _mod, zipper -> - zipper - |> Igniter.Code.Module.move_to_use(resource_mods(igniter)) - |> case do - {:ok, _} -> - true +if Code.ensure_loaded?(Igniter) do + defmodule Ash.Resource.Igniter do + @moduledoc """ + Codemods for working with Ash.Resource modules + + ## Important Details + + This interrogates *the source code* of a resource, not its ultimate compiled state. + What this means, is that things like `defines_attribute` will not return `true` if + the attribute is added by an extension. Only if it appears literally in the source code + of the resource or one of its `Spark.Dsl.Fragment`s. + """ + + @doc "List all resource modules found in the project" + def list_resources(igniter) do + Igniter.Project.Module.find_all_matching_modules(igniter, fn _mod, zipper -> + zipper + |> Igniter.Code.Module.move_to_use(resource_mods(igniter)) + |> case do + {:ok, _} -> + true - _ -> - false - end - end) - end - - @doc "Gets the domain from the given resource module" - @spec domain(Igniter.t(), Ash.Resource.t()) :: - {:ok, Igniter.t(), Ash.Domain.t()} | {:error, Igniter.t()} - def domain(igniter, resource) do - case Igniter.Project.Module.find_module(igniter, resource) do - {:ok, {igniter, _source, zipper}} -> - with {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), - {:ok, zipper} <- - Igniter.Code.Module.move_to_use(zipper, resource_mods(igniter)), - {:ok, zipper} <- - Igniter.Code.Function.move_to_nth_argument(zipper, 1), - {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :domain), - module when not is_nil(module) <- - Igniter.Project.Module.to_module_name(zipper.node) do - {:ok, igniter, module} - else _ -> - {:error, igniter} + false end - - {:error, igniter} -> - {:error, igniter} + end) end - end - - def resource_mods(igniter) do - app_name = Igniter.Project.Application.app_name(igniter) - [Ash.Resource | List.wrap(Application.get_env(app_name, :base_resources))] - end - - @doc "Adds the given code block to the block of the resource specified" - def add_block(igniter, resource, block, chunk) do - Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> - with {:ok, zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - block, - 1 - ), - {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do - {:ok, Igniter.Code.Common.add_code(zipper, chunk)} - else - _ -> - block_with_chunk = """ - #{block} do - #{chunk} + @doc "Gets the domain from the given resource module" + @spec domain(Igniter.t(), Ash.Resource.t()) :: + {:ok, Igniter.t(), Ash.Domain.t()} | {:error, Igniter.t()} + def domain(igniter, resource) do + case Igniter.Project.Module.find_module(igniter, resource) do + {:ok, {igniter, _source, zipper}} -> + with {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), + {:ok, zipper} <- + Igniter.Code.Module.move_to_use(zipper, resource_mods(igniter)), + {:ok, zipper} <- + Igniter.Code.Function.move_to_nth_argument(zipper, 1), + {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :domain), + module when not is_nil(module) <- + Igniter.Project.Module.to_module_name(zipper.node) do + {:ok, igniter, module} + else + _ -> + {:error, igniter} end - """ - {:ok, Igniter.Code.Common.add_code(zipper, block_with_chunk)} + {:error, igniter} -> + {:error, igniter} end - end) - end + end - @doc "Adds a bypass to the top of the resource's `policies` block" - def add_bypass(igniter, resource, condition, body) do - Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> - with {:ok, zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :policies, - 1 - ), - {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do - bypass = - quote do - bypass unquote(condition) do - unquote(body) + def resource_mods(igniter) do + app_name = Igniter.Project.Application.app_name(igniter) + + [Ash.Resource | List.wrap(Application.get_env(app_name, :base_resources))] + end + + @doc "Adds the given code block to the block of the resource specified" + def add_block(igniter, resource, block, chunk) do + Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + block, + 1 + ), + {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do + {:ok, Igniter.Code.Common.add_code(zipper, chunk)} + else + _ -> + block_with_chunk = """ + #{block} do + #{chunk} end - end - |> Sourceror.to_string() - |> Sourceror.parse_string!() + """ - {:ok, Igniter.Code.Common.add_code(zipper, bypass, placement: :before)} - else - _ -> + {:ok, Igniter.Code.Common.add_code(zipper, block_with_chunk)} + end + end) + end + + @doc "Adds a bypass to the top of the resource's `policies` block" + def add_bypass(igniter, resource, condition, body) do + Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :policies, + 1 + ), + {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do bypass = quote do - policies do - bypass unquote(condition) do - unquote(body) - end + bypass unquote(condition) do + unquote(body) end end |> Sourceror.to_string() |> Sourceror.parse_string!() - {:ok, Igniter.Code.Common.add_code(zipper, bypass)} - end - end) - end + {:ok, Igniter.Code.Common.add_code(zipper, bypass, placement: :before)} + else + _ -> + bypass = + quote do + policies do + bypass unquote(condition) do + unquote(body) + end + end + end + |> Sourceror.to_string() + |> Sourceror.parse_string!() - @doc "Adds a policy to the bottom of the resource's `policies` block" - def add_policy(igniter, resource, condition, body) do - Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> - with {:ok, zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :policies, - 1 - ), - {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do - policy = - quote do - policy unquote(condition) do - unquote(body) - end - end - |> Sourceror.to_string() - |> Sourceror.parse_string!() + {:ok, Igniter.Code.Common.add_code(zipper, bypass)} + end + end) + end - {:ok, Igniter.Code.Common.add_code(zipper, policy, placement: :after)} - else - _ -> + @doc "Adds a policy to the bottom of the resource's `policies` block" + def add_policy(igniter, resource, condition, body) do + Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :policies, + 1 + ), + {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do policy = quote do - policies do - policy unquote(condition) do - unquote(body) - end + policy unquote(condition) do + unquote(body) end end |> Sourceror.to_string() |> Sourceror.parse_string!() - {:ok, Igniter.Code.Common.add_code(zipper, policy)} - end - end) - end + {:ok, Igniter.Code.Common.add_code(zipper, policy, placement: :after)} + else + _ -> + policy = + quote do + policies do + policy unquote(condition) do + unquote(body) + end + end + end + |> Sourceror.to_string() + |> Sourceror.parse_string!() + + {:ok, Igniter.Code.Common.add_code(zipper, policy)} + end + end) + end - @doc "Returns true if the given resource defines an attribute with the provided name" - @spec defines_attribute(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false} - def defines_attribute(igniter, resource, name) do - {igniter, defines?} = - if name in [:inserted_at, :updated_at] do - has_timestamps_call(igniter, resource) + @doc "Returns true if the given resource defines an attribute with the provided name" + @spec defines_attribute(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false} + def defines_attribute(igniter, resource, name) do + {igniter, defines?} = + if name in [:inserted_at, :updated_at] do + has_timestamps_call(igniter, resource) + else + {igniter, false} + end + + if defines? do + {igniter, true} else - {igniter, false} + Spark.Igniter.find(igniter, resource, fn _, zipper -> + with {:ok, zipper} <- enter_section(zipper, :attributes), + {:ok, _zipper} <- + move_to_one_of_function_call_in_current_scope( + zipper, + [ + :attribute, + :uuid_primary_key, + :integer_primary_key, + :uuid_v7_primary_key, + :create_timestamp, + :update_timestamp + ], + [2, 3], + &Igniter.Code.Function.argument_equals?(&1, 0, name) + ) do + {:ok, true} + else + _ -> + :error + end + end) + |> case do + {:ok, igniter, _module, _value} -> + {igniter, true} + + {:error, igniter} -> + {igniter, false} + end end + end - if defines? do - {igniter, true} - else + @doc "Returns true if the given resource defines an identity with the provided name" + @spec defines_identity(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false} + def defines_identity(igniter, resource, name) do Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :attributes), + with {:ok, zipper} <- enter_section(zipper, :identities), {:ok, _zipper} <- move_to_one_of_function_call_in_current_scope( zipper, - [ - :attribute, - :uuid_primary_key, - :integer_primary_key, - :uuid_v7_primary_key, - :create_timestamp, - :update_timestamp - ], + [:identity], [2, 3], &Igniter.Code.Function.argument_equals?(&1, 0, name) ) do @@ -199,61 +228,86 @@ defmodule Ash.Resource.Igniter do {igniter, false} end end - end - @doc "Returns true if the given resource defines an identity with the provided name" - @spec defines_identity(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false} - def defines_identity(igniter, resource, name) do - Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :identities), - {:ok, _zipper} <- - move_to_one_of_function_call_in_current_scope( - zipper, - [:identity], - [2, 3], - &Igniter.Code.Function.argument_equals?(&1, 0, name) - ) do - {:ok, true} - else - _ -> - :error - end - end) - |> case do - {:ok, igniter, _module, _value} -> + @doc "Returns true if the given resource defines an action with the provided name" + @spec defines_action(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false} + def defines_action(igniter, resource, name) do + {igniter, defines?} = + if name in [:create, :read, :update, :destroy] do + has_default_action(igniter, resource, name) + else + {igniter, false} + end + + if defines? do {igniter, true} + else + Spark.Igniter.find(igniter, resource, fn _, zipper -> + with {:ok, zipper} <- enter_section(zipper, :actions), + {:ok, _zipper} <- + move_to_one_of_function_call_in_current_scope( + zipper, + [ + :create, + :update, + :read, + :destroy, + :action + ], + [2, 3, 4], + &Igniter.Code.Function.argument_equals?(&1, 0, name) + ) do + {:ok, true} + else + _ -> + :error + end + end) + |> case do + {:ok, igniter, _module, _value} -> + {igniter, true} - {:error, igniter} -> - {igniter, false} + {:error, igniter} -> + {igniter, false} + end + end end - end - @doc "Returns true if the given resource defines an action with the provided name" - @spec defines_action(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false} - def defines_action(igniter, resource, name) do - {igniter, defines?} = - if name in [:create, :read, :update, :destroy] do - has_default_action(igniter, resource, name) - else - {igniter, false} + @spec ensure_primary_action( + Igniter.t(), + Ash.Resource.t(), + :create | :read | :update | :destroy + ) :: + Igniter.t() + def ensure_primary_action(igniter, resource, type) do + case has_default_action(igniter, resource, type) do + {igniter, true} -> + igniter + + {igniter, false} -> + case has_action_with_primary(igniter, resource, type) do + {igniter, true} -> igniter + {igniter, false} -> add_default_action(igniter, resource, type) + end end + end - if defines? do - {igniter, true} - else + @doc "Returns true if the given resource defines a relationship with the provided name" + @spec defines_relationship(Igniter.t(), Ash.Resource.t(), atom()) :: + {Igniter.t(), true | false} + def defines_relationship(igniter, resource, name) do Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :actions), + with {:ok, zipper} <- enter_section(zipper, :relationships), {:ok, _zipper} <- move_to_one_of_function_call_in_current_scope( zipper, [ - :create, - :update, - :read, - :destroy, - :action + :has_one, + :has_many, + :belongs_to, + :many_to_many ], - [2, 3, 4], + [2, 3], &Igniter.Code.Function.argument_equals?(&1, 0, name) ) do {:ok, true} @@ -270,324 +324,277 @@ defmodule Ash.Resource.Igniter do {igniter, false} end end - end - @spec ensure_primary_action(Igniter.t(), Ash.Resource.t(), :create | :read | :update | :destroy) :: - Igniter.t() - def ensure_primary_action(igniter, resource, type) do - case has_default_action(igniter, resource, type) do - {igniter, true} -> - igniter - - {igniter, false} -> - case has_action_with_primary(igniter, resource, type) do - {igniter, true} -> igniter - {igniter, false} -> add_default_action(igniter, resource, type) - end - end - end + @doc "Adds the given code block to the resource's `attributes` block if there is no existing attribute with the given name" + def add_new_attribute(igniter, resource, name, attribute) do + {igniter, defines?} = defines_attribute(igniter, resource, name) - @doc "Returns true if the given resource defines a relationship with the provided name" - @spec defines_relationship(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false} - def defines_relationship(igniter, resource, name) do - Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :relationships), - {:ok, _zipper} <- - move_to_one_of_function_call_in_current_scope( - zipper, - [ - :has_one, - :has_many, - :belongs_to, - :many_to_many - ], - [2, 3], - &Igniter.Code.Function.argument_equals?(&1, 0, name) - ) do - {:ok, true} + if defines? do + igniter else - _ -> - :error + add_attribute(igniter, resource, attribute) end - end) - |> case do - {:ok, igniter, _module, _value} -> - {igniter, true} - - {:error, igniter} -> - {igniter, false} end - end - @doc "Adds the given code block to the resource's `attributes` block if there is no existing attribute with the given name" - def add_new_attribute(igniter, resource, name, attribute) do - {igniter, defines?} = defines_attribute(igniter, resource, name) + @doc "Adds the given code block to the resource's `identities` block if there is no existing identity with the given name" + def add_new_identity(igniter, resource, name, identity) do + {igniter, defines?} = defines_identity(igniter, resource, name) - if defines? do - igniter - else - add_attribute(igniter, resource, attribute) + if defines? do + igniter + else + add_identity(igniter, resource, identity) + end end - end - @doc "Adds the given code block to the resource's `identities` block if there is no existing identity with the given name" - def add_new_identity(igniter, resource, name, identity) do - {igniter, defines?} = defines_identity(igniter, resource, name) - - if defines? do - igniter - else - add_identity(igniter, resource, identity) + @doc "Adds the given code block to the resource's `identities` block" + def add_identity(igniter, resource, identity) do + add_block(igniter, resource, :identities, identity) end - end - @doc "Adds the given code block to the resource's `identities` block" - def add_identity(igniter, resource, identity) do - add_block(igniter, resource, :identities, identity) - end + @doc "Adds the given code block to the resource's `attributes` block" + def add_attribute(igniter, resource, attribute) do + add_block(igniter, resource, :attributes, attribute) + end - @doc "Adds the given code block to the resource's `attributes` block" - def add_attribute(igniter, resource, attribute) do - add_block(igniter, resource, :attributes, attribute) - end + @doc "Adds an action if it doesn't already exist" + def add_new_action(igniter, resource, name, action) do + {igniter, defines?} = defines_action(igniter, resource, name) - @doc "Adds an action if it doesn't already exist" - def add_new_action(igniter, resource, name, action) do - {igniter, defines?} = defines_action(igniter, resource, name) + if defines? do + igniter + else + add_action(igniter, resource, action) + end + end - if defines? do - igniter - else - add_action(igniter, resource, action) + @doc "Adds the given code block to the resource's `actions` block" + def add_action(igniter, resource, action) do + add_block(igniter, resource, :actions, action) end - end - @doc "Adds the given code block to the resource's `actions` block" - def add_action(igniter, resource, action) do - add_block(igniter, resource, :actions, action) - end + @doc "Adds the given code block to the resource's `relationships` block" + def add_new_relationship(igniter, resource, name, relationship) do + {igniter, defines?} = defines_relationship(igniter, resource, name) - @doc "Adds the given code block to the resource's `relationships` block" - def add_new_relationship(igniter, resource, name, relationship) do - {igniter, defines?} = defines_relationship(igniter, resource, name) + if defines? do + igniter + else + add_block(igniter, resource, :relationships, relationship) + end + end - if defines? do - igniter - else + @doc "Adds the given code block to the resource's `relationships` block" + def add_relationship(igniter, resource, relationship) do add_block(igniter, resource, :relationships, relationship) end - end - @doc "Adds the given code block to the resource's `relationships` block" - def add_relationship(igniter, resource, relationship) do - add_block(igniter, resource, :relationships, relationship) - end + @doc "Adds the given code block to the resource's `resource` block" + def add_resource_configuration(igniter, resource, resource_configuration) do + add_block(igniter, resource, :resource, resource_configuration) + end - @doc "Adds the given code block to the resource's `resource` block" - def add_resource_configuration(igniter, resource, resource_configuration) do - add_block(igniter, resource, :resource, resource_configuration) - end + @doc "Ensures that created_at and updated_at timestamps exist on the resource" + def ensure_timestamps(igniter, resource) do + {igniter, defines_inserted_at?} = defines_attribute(igniter, resource, :inserted_at) + {igniter, defines_created_at?} = defines_attribute(igniter, resource, :created_at) + defines_create_timestamp? = defines_inserted_at? || defines_created_at? + {igniter, defines_updated_at?} = defines_attribute(igniter, resource, :updated_at) - @doc "Ensures that created_at and updated_at timestamps exist on the resource" - def ensure_timestamps(igniter, resource) do - {igniter, defines_inserted_at?} = defines_attribute(igniter, resource, :inserted_at) - {igniter, defines_created_at?} = defines_attribute(igniter, resource, :created_at) - defines_create_timestamp? = defines_inserted_at? || defines_created_at? - {igniter, defines_updated_at?} = defines_attribute(igniter, resource, :updated_at) + igniter + |> then(fn igniter -> + if defines_create_timestamp? do + igniter + else + add_create_timestamp_call(igniter, resource) + end + end) + |> then(fn igniter -> + if defines_updated_at? do + igniter + else + add_update_timestamp_call(igniter, resource) + end + end) + end - igniter - |> then(fn igniter -> - if defines_create_timestamp? do - igniter - else - add_create_timestamp_call(igniter, resource) - end - end) - |> then(fn igniter -> - if defines_updated_at? do - igniter - else - add_update_timestamp_call(igniter, resource) + defp enter_section(zipper, name) do + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + name, + 1 + ) do + Igniter.Code.Common.move_to_do_block(zipper) end - end) - end + end - defp enter_section(zipper, name) do - with {:ok, zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( + defp move_to_one_of_function_call_in_current_scope(zipper, [name], arities, pred) do + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + name, + arities, + pred + ) + end + + defp move_to_one_of_function_call_in_current_scope(zipper, [name | rest], arities, pred) do + case Igniter.Code.Function.move_to_function_call_in_current_scope( zipper, name, - 1 + arities, + pred ) do - Igniter.Code.Common.move_to_do_block(zipper) - end - end - - defp move_to_one_of_function_call_in_current_scope(zipper, [name], arities, pred) do - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - name, - arities, - pred - ) - end - - defp move_to_one_of_function_call_in_current_scope(zipper, [name | rest], arities, pred) do - case Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - name, - arities, - pred - ) do - {:ok, zipper} -> {:ok, zipper} - :error -> move_to_one_of_function_call_in_current_scope(zipper, rest, arities, pred) + {:ok, zipper} -> {:ok, zipper} + :error -> move_to_one_of_function_call_in_current_scope(zipper, rest, arities, pred) + end end - end - defp has_default_action(igniter, resource, type) do - Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :actions), - {:ok, _zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :defaults, - 1, - fn zipper -> - with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 0), - {:ok, _zipper} <- - Igniter.Code.List.move_to_list_item(zipper, fn zipper -> - if Igniter.Code.Tuple.tuple?(zipper) do - case Igniter.Code.Tuple.tuple_elem(zipper, 0) do - {:ok, zipper} -> - Igniter.Code.Common.nodes_equal?(zipper, type) - - _ -> - false + defp has_default_action(igniter, resource, type) do + Spark.Igniter.find(igniter, resource, fn _, zipper -> + with {:ok, zipper} <- enter_section(zipper, :actions), + {:ok, _zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :defaults, + 1, + fn zipper -> + with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 0), + {:ok, _zipper} <- + Igniter.Code.List.move_to_list_item(zipper, fn zipper -> + if Igniter.Code.Tuple.tuple?(zipper) do + case Igniter.Code.Tuple.tuple_elem(zipper, 0) do + {:ok, zipper} -> + Igniter.Code.Common.nodes_equal?(zipper, type) + + _ -> + false + end + else + Igniter.Code.Common.nodes_equal?(zipper, type) end - else - Igniter.Code.Common.nodes_equal?(zipper, type) - end - end) do - true - else - _ -> false + end) do + true + else + _ -> false + end end - end - ) do - {:ok, true} - else - _ -> :error - end - end) - |> case do - {:ok, igniter, _module, _value} -> - {igniter, true} + ) do + {:ok, true} + else + _ -> :error + end + end) + |> case do + {:ok, igniter, _module, _value} -> + {igniter, true} - {:error, igniter} -> - {igniter, false} + {:error, igniter} -> + {igniter, false} + end end - end - @spec has_action_with_primary(Igniter.t(), Ash.Resource.t(), atom()) :: - {Igniter.t(), true | false} - def has_action_with_primary(igniter, resource, type) do - Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :actions), - {:ok, _zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, type, [1, 2]) do - case Igniter.Code.Common.move_to_do_block(zipper) do - {:ok, zipper} -> - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :primary?, - 1, - &Igniter.Code.Function.argument_equals?(&1, 0, true) - ) - - :error -> - with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1), - {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :primary?), - true <- Igniter.Code.Common.nodes_equal?(zipper, true) do - {:ok, true} - end + @spec has_action_with_primary(Igniter.t(), Ash.Resource.t(), atom()) :: + {Igniter.t(), true | false} + def has_action_with_primary(igniter, resource, type) do + Spark.Igniter.find(igniter, resource, fn _, zipper -> + with {:ok, zipper} <- enter_section(zipper, :actions), + {:ok, _zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, type, [1, 2]) do + case Igniter.Code.Common.move_to_do_block(zipper) do + {:ok, zipper} -> + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :primary?, + 1, + &Igniter.Code.Function.argument_equals?(&1, 0, true) + ) + + :error -> + with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1), + {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :primary?), + true <- Igniter.Code.Common.nodes_equal?(zipper, true) do + {:ok, true} + end + end end - end - end) - |> case do - {:ok, igniter, _module, _value} -> - {igniter, true} + end) + |> case do + {:ok, igniter, _module, _value} -> + {igniter, true} - {:error, igniter} -> - {igniter, false} + {:error, igniter} -> + {igniter, false} + end end - end - defp add_default_action(igniter, resource, type) do - Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :actions), - {:ok, _zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :defaults, - 1 - ) do - {:ok, true} - else - _ -> :error + defp add_default_action(igniter, resource, type) do + Spark.Igniter.find(igniter, resource, fn _, zipper -> + with {:ok, zipper} <- enter_section(zipper, :actions), + {:ok, _zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :defaults, + 1 + ) do + {:ok, true} + else + _ -> :error + end + end) + |> case do + {:ok, igniter, module, _value} -> + Igniter.Project.Module.find_and_update_module!(igniter, module, fn zipper -> + with {:ok, zipper} <- enter_section(zipper, :actions), + {:ok, _zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :defaults, + 1 + ), + {:ok, zipper} <- Igniter.Code.List.prepend_new_to_list(zipper, type) do + {:ok, zipper} + else + _ -> + {:error, + "Failed to add a default action of type #{inspect(type)} in #{inspect(module)}"} + end + end) + + {:error, igniter} -> + igniter end - end) - |> case do - {:ok, igniter, module, _value} -> - Igniter.Project.Module.find_and_update_module!(igniter, module, fn zipper -> - with {:ok, zipper} <- enter_section(zipper, :actions), - {:ok, _zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :defaults, - 1 - ), - {:ok, zipper} <- Igniter.Code.List.prepend_new_to_list(zipper, type) do - {:ok, zipper} - else - _ -> - {:error, - "Failed to add a default action of type #{inspect(type)} in #{inspect(module)}"} - end - end) + end - {:error, igniter} -> - igniter + defp add_create_timestamp_call(igniter, resource) do + add_block(igniter, resource, :attributes, "create_timestamp :created_at") end - end - defp add_create_timestamp_call(igniter, resource) do - add_block(igniter, resource, :attributes, "create_timestamp :created_at") - end + defp add_update_timestamp_call(igniter, resource) do + add_block(igniter, resource, :attributes, "update_timestamp :updated_at") + end - defp add_update_timestamp_call(igniter, resource) do - add_block(igniter, resource, :attributes, "update_timestamp :updated_at") - end + defp has_timestamps_call(igniter, resource) do + Spark.Igniter.find(igniter, resource, fn _, zipper -> + with {:ok, zipper} <- enter_section(zipper, :attributes), + {:ok, _zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :timestamps, + 1 + ) do + {:ok, true} + else + _ -> :error + end + end) + |> case do + {:ok, igniter, _module, _value} -> + {igniter, true} - defp has_timestamps_call(igniter, resource) do - Spark.Igniter.find(igniter, resource, fn _, zipper -> - with {:ok, zipper} <- enter_section(zipper, :attributes), - {:ok, _zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :timestamps, - 1 - ) do - {:ok, true} - else - _ -> :error + {:error, igniter} -> + {igniter, false} end - end) - |> case do - {:ok, igniter, _module, _value} -> - {igniter, true} - - {:error, igniter} -> - {igniter, false} end end end diff --git a/lib/mix/tasks/gen/ash.gen.base_resource.ex b/lib/mix/tasks/gen/ash.gen.base_resource.ex index 59eb3dea4..bcf4aa1c3 100644 --- a/lib/mix/tasks/gen/ash.gen.base_resource.ex +++ b/lib/mix/tasks/gen/ash.gen.base_resource.ex @@ -1,75 +1,104 @@ -defmodule Mix.Tasks.Ash.Gen.BaseResource do - @moduledoc """ - Generates a base resource +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Ash.Gen.BaseResource do + @moduledoc """ + Generates a base resource - ## Example + ## Example - ```bash - mix ash.gen.base_resource MyApp.Resource - ``` - """ - @shortdoc "Generates a base resource. This is a module that you can use instead of `Ash.Resource`, for consistency." - use Igniter.Mix.Task + ```bash + mix ash.gen.base_resource MyApp.Resource + ``` + """ + @shortdoc "Generates a base resource. This is a module that you can use instead of `Ash.Resource`, for consistency." + use Igniter.Mix.Task - @impl true - def info(_argv, _parent) do - %Igniter.Mix.Task.Info{ - positional: [:resource] - } - end + @impl true + def info(_argv, _parent) do + %Igniter.Mix.Task.Info{ + positional: [:resource] + } + end - @impl Igniter.Mix.Task - def igniter(igniter) do - base_resource = igniter.args.positional.resource - base_resource = Igniter.Project.Module.parse(base_resource) + @impl Igniter.Mix.Task + def igniter(igniter) do + base_resource = igniter.args.positional.resource + base_resource = Igniter.Project.Module.parse(base_resource) - app_name = Igniter.Project.Application.app_name(igniter) + app_name = Igniter.Project.Application.app_name(igniter) - # need `Igniter.glob(igniter, path, filter)` to get all existing or new files that match a path & condition - # for each file that defines a resource that uses `Ash.Resource`, that is "further down" from this file, - # replace what it uses with the new base resource + # need `Igniter.glob(igniter, path, filter)` to get all existing or new files that match a path & condition + # for each file that defines a resource that uses `Ash.Resource`, that is "further down" from this file, + # replace what it uses with the new base resource - igniter - |> Igniter.Project.Module.create_module(base_resource, """ - defmacro __using__(opts) do - quote do - use Ash.Resource, unquote(opts) + igniter + |> Igniter.Project.Module.create_module(base_resource, """ + defmacro __using__(opts) do + quote do + use Ash.Resource, unquote(opts) + end end + """) + |> Igniter.Project.Config.configure( + "config.exs", + app_name, + [:base_resources], + [base_resource], + updater: fn list -> + Igniter.Code.List.prepend_new_to_list( + list, + base_resource + ) + end + ) + |> Igniter.update_all_elixir_files(fn zipper -> + with {:ok, zipper} <- Igniter.Code.Module.move_to_module_using(zipper, Ash.Resource), + {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :use, + 2, + fn function_call -> + function_call + |> Igniter.Code.Function.argument_matches_predicate?( + 0, + &Igniter.Code.Common.nodes_equal?(&1, Ash.Resource) + ) + end + ), + {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 0) do + Sourceror.Zipper.replace(zipper, base_resource) + else + _ -> + zipper + end + end) + end + end +else + defmodule Mix.Tasks.Ash.Gen.BaseResource do + @moduledoc """ + Generates a base resource + + ## Example + + ```bash + mix ash.gen.base_resource MyApp.Resource + ``` + """ + @shortdoc "Generates a base resource. This is a module that you can use instead of `Ash.Resource`, for consistency." + + use Mix.Task + + def run(_argv) do + Mix.shell().error(""" + The task 'ash.gen.base_resource' requires igniter to be run. + + Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter + """) + + exit({:shutdown, 1}) end - """) - |> Igniter.Project.Config.configure( - "config.exs", - app_name, - [:base_resources], - [base_resource], - updater: fn list -> - Igniter.Code.List.prepend_new_to_list( - list, - base_resource - ) - end - ) - |> Igniter.update_all_elixir_files(fn zipper -> - with {:ok, zipper} <- Igniter.Code.Module.move_to_module_using(zipper, Ash.Resource), - {:ok, zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :use, - 2, - fn function_call -> - function_call - |> Igniter.Code.Function.argument_matches_predicate?( - 0, - &Igniter.Code.Common.nodes_equal?(&1, Ash.Resource) - ) - end - ), - {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 0) do - Sourceror.Zipper.replace(zipper, base_resource) - else - _ -> - zipper - end - end) end end diff --git a/lib/mix/tasks/gen/ash.gen.domain.ex b/lib/mix/tasks/gen/ash.gen.domain.ex index b175a400b..79557cbd5 100644 --- a/lib/mix/tasks/gen/ash.gen.domain.ex +++ b/lib/mix/tasks/gen/ash.gen.domain.ex @@ -1,55 +1,86 @@ -defmodule Mix.Tasks.Ash.Gen.Domain do - @example "mix ash.gen.domain MyApp.Accounts" - @moduledoc """ - Generates an Ash.Domain - - ## Example - - ```bash - #{@example} - ``` - """ - - @shortdoc "Generates an Ash.Domain" - use Igniter.Mix.Task - - @impl Igniter.Mix.Task - def info(_argv, _parent) do - %Igniter.Mix.Task.Info{ - positional: [:domain], - example: @example - } - end +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Ash.Gen.Domain do + @example "mix ash.gen.domain MyApp.Accounts" + @moduledoc """ + Generates an Ash.Domain + + ## Example + + ```bash + #{@example} + ``` + """ - @impl Igniter.Mix.Task - def igniter(igniter) do - domain = Igniter.Project.Module.parse(igniter.args.positional.domain) + @shortdoc "Generates an Ash.Domain" + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _parent) do + %Igniter.Mix.Task.Info{ + positional: [:domain], + example: @example + } + end - app_name = Igniter.Project.Application.app_name(igniter) - {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, domain) + @impl Igniter.Mix.Task + def igniter(igniter) do + domain = Igniter.Project.Module.parse(igniter.args.positional.domain) - if "--ignore-if-exists" in igniter.args.argv_flags && exists? do - igniter - else - igniter - |> Igniter.Project.Module.create_module(domain, """ - use Ash.Domain + app_name = Igniter.Project.Application.app_name(igniter) + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, domain) - resources do + if "--ignore-if-exists" in igniter.args.argv_flags && exists? do + igniter + else + igniter + |> Igniter.Project.Module.create_module(domain, """ + use Ash.Domain + + resources do + end + """) + |> Igniter.Project.Config.configure( + "config.exs", + app_name, + [:ash_domains], + [domain], + updater: fn list -> + Igniter.Code.List.prepend_new_to_list( + list, + domain + ) + end + ) end + end + end +else + defmodule Mix.Tasks.Ash.Gen.Domain do + @example "mix ash.gen.domain MyApp.Accounts" + @moduledoc """ + Generates an Ash.Domain + + ## Example + + ```bash + #{@example} + ``` + """ + + @shortdoc "Generates an Ash.Domain" + + use Mix.Task + + def run(_argv) do + Mix.shell().error(""" + The task 'ash.gen.domain' requires igniter to be run. + + Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter """) - |> Igniter.Project.Config.configure( - "config.exs", - app_name, - [:ash_domains], - [domain], - updater: fn list -> - Igniter.Code.List.prepend_new_to_list( - list, - domain - ) - end - ) + + exit({:shutdown, 1}) end end end diff --git a/lib/mix/tasks/gen/ash.gen.enum.ex b/lib/mix/tasks/gen/ash.gen.enum.ex index d2645a299..9957bc544 100644 --- a/lib/mix/tasks/gen/ash.gen.enum.ex +++ b/lib/mix/tasks/gen/ash.gen.enum.ex @@ -1,73 +1,108 @@ -defmodule Mix.Tasks.Ash.Gen.Enum do - @example "mix ash.gen.enum MyApp.Support.Ticket.Types.Status open,closed --short-name ticket_status" - @moduledoc """ - Generates an Ash.Type.Enum - - ## Example - - ```bash - #{@example} - ``` - - ## Options - - - `--short-name`, `-s`: Register the type under the provided shortname, so it can be referenced like `:short_name` instead of the module name. - """ - - @shortdoc "Generates an Ash.Type.Enum" - use Igniter.Mix.Task - - @impl Igniter.Mix.Task - def info(_argv, _parent) do - %Igniter.Mix.Task.Info{ - schema: [ - short_name: :string - ], - example: @example, - positional: [:module_name, :types], - aliases: [ - s: :short_name - ] - } - end +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Ash.Gen.Enum do + @example "mix ash.gen.enum MyApp.Support.Ticket.Types.Status open,closed --short-name ticket_status" + @moduledoc """ + Generates an Ash.Type.Enum - @impl Igniter.Mix.Task - def igniter(igniter) do - module_name = igniter.args.positional.module_name - types = igniter.args.positional.types - opts = igniter.args.options + ## Example - enum = Igniter.Project.Module.parse(module_name) - file_name = Igniter.Project.Module.proper_location(igniter, enum) + ```bash + #{@example} + ``` - short_name = - if opts[:short_name] do - String.to_atom(opts[:short_name]) - end + ## Options + + - `--short-name`, `-s`: Register the type under the provided shortname, so it can be referenced like `:short_name` instead of the module name. + """ - types = - types - |> String.split(",") - |> Enum.map(&String.to_atom/1) + @shortdoc "Generates an Ash.Type.Enum" + use Igniter.Mix.Task - igniter - |> Igniter.create_new_file(file_name, """ - defmodule #{inspect(enum)} do - use Ash.Type.Enum, values: #{inspect(types)} + @impl Igniter.Mix.Task + def info(_argv, _parent) do + %Igniter.Mix.Task.Info{ + schema: [ + short_name: :string + ], + example: @example, + positional: [:module_name, :types], + aliases: [ + s: :short_name + ] + } end - """) - |> then(fn igniter -> - if short_name do - Igniter.Project.Config.configure( - igniter, - "config.exs", - :ash, - [:custom_types, short_name], - enum - ) - else - igniter + + @impl Igniter.Mix.Task + def igniter(igniter) do + module_name = igniter.args.positional.module_name + types = igniter.args.positional.types + opts = igniter.args.options + + enum = Igniter.Project.Module.parse(module_name) + file_name = Igniter.Project.Module.proper_location(igniter, enum) + + short_name = + if opts[:short_name] do + String.to_atom(opts[:short_name]) + end + + types = + types + |> String.split(",") + |> Enum.map(&String.to_atom/1) + + igniter + |> Igniter.create_new_file(file_name, """ + defmodule #{inspect(enum)} do + use Ash.Type.Enum, values: #{inspect(types)} end - end) + """) + |> then(fn igniter -> + if short_name do + Igniter.Project.Config.configure( + igniter, + "config.exs", + :ash, + [:custom_types, short_name], + enum + ) + else + igniter + end + end) + end + end +else + defmodule Mix.Tasks.Ash.Gen.Enum do + @example "mix ash.gen.enum MyApp.Support.Ticket.Types.Status open,closed --short-name ticket_status" + @moduledoc """ + Generates an Ash.Type.Enum + + ## Example + + ```bash + #{@example} + ``` + + ## Options + + - `--short-name`, `-s`: Register the type under the provided shortname, so it can be referenced like `:short_name` instead of the module name. + """ + + @shortdoc "Generates an Ash.Type.Enum" + + use Mix.Task + + def run(_argv) do + Mix.shell().error(""" + The task 'ash.gen.enum' requires igniter to be run. + + Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter + """) + + exit({:shutdown, 1}) + end end end diff --git a/lib/mix/tasks/gen/ash.gen.resource.ex b/lib/mix/tasks/gen/ash.gen.resource.ex index a1c509618..47adefc94 100644 --- a/lib/mix/tasks/gen/ash.gen.resource.ex +++ b/lib/mix/tasks/gen/ash.gen.resource.ex @@ -1,399 +1,423 @@ -defmodule Mix.Tasks.Ash.Gen.Resource do - @example """ - mix ash.gen.resource Helpdesk.Support.Ticket \\ - --default-actions read \\ - --uuid-primary-key id \\ - --attribute subject:string:required:public \\ - --relationship belongs_to:representative:Helpdesk.Support.Representative \\ - --timestamps \\ - --extend postgres,graphql - """ - @moduledoc """ - Generate and configure an Ash.Resource. - - If the domain does not exist, we create it. If it does, we add the resource to it if it is not already present. - - ## Example - - ```bash - #{@example} - ``` - - ## Options - - * `--attribute` or `-a` - An attribute or comma separated list of attributes to add, as `name:type`. Modifiers: `primary_key`, `public`, `sensitive`, and `required`. i.e `-a name:string:required` - * `--relationship` or `-r` - A relationship or comma separated list of relationships to add, as `type:name:dest`. Modifiers: `public`. `belongs_to` only modifiers: `primary_key`, `sensitive`, and `required`. i.e `-r belongs_to:author:MyApp.Accounts.Author:required` - * `--default-actions` - A csv list of default action types to add. The `create` and `update` actions accept the public attributes being added. - * `--uuid-primary-key` or `-u` - Adds a UUIDv4 primary key with that name. i.e `-u id` - * `--uuid-v7-primary-key` - Adds a UUIDv7 primary key with that name. - * `--integer-primary-key` or `-i` - Adds an integer primary key with that name. i.e `-i id` - * `--domain` or `-d` - The domain module to add the resource to. i.e `-d MyApp.MyDomain`. This defaults to the resource's module name, minus the last segment. - * `--extend` or `-e` - A comma separated list of modules or builtins to extend the resource with. i.e `-e postgres,Some.Extension` - * `--base` or `-b` - The base module to use for the resource. i.e `-b Ash.Resource`. Requires that the module is in `config :your_app, :base_resources` - * `--timestamps` or `-t` - If set adds `inserted_at` and `updated_at` timestamps to the resource. - """ - - @shortdoc "Generate and configure an Ash.Resource." - use Igniter.Mix.Task - - @impl Igniter.Mix.Task - def info(argv, _parent) do - for {key, cmd} <- [da: "--default-actions", u7: "--uuid-v7-primary-key"] do - if "-#{key}" in argv do - Mix.shell().error(""" - The `-#{key}` alias has been removed as multi-char aliases are deprecated in OptionParser. - Please use `--#{cmd}` instead. - """) - - Mix.shell().exit({:shutdown, 1}) +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Ash.Gen.Resource do + @example """ + mix ash.gen.resource Helpdesk.Support.Ticket \\ + --default-actions read \\ + --uuid-primary-key id \\ + --attribute subject:string:required:public \\ + --relationship belongs_to:representative:Helpdesk.Support.Representative \\ + --timestamps \\ + --extend postgres,graphql + """ + @moduledoc """ + Generate and configure an Ash.Resource. + + If the domain does not exist, we create it. If it does, we add the resource to it if it is not already present. + + ## Example + + ```bash + #{@example} + ``` + + ## Options + + * `--attribute` or `-a` - An attribute or comma separated list of attributes to add, as `name:type`. Modifiers: `primary_key`, `public`, `sensitive`, and `required`. i.e `-a name:string:required` + * `--relationship` or `-r` - A relationship or comma separated list of relationships to add, as `type:name:dest`. Modifiers: `public`. `belongs_to` only modifiers: `primary_key`, `sensitive`, and `required`. i.e `-r belongs_to:author:MyApp.Accounts.Author:required` + * `--default-actions` - A csv list of default action types to add. The `create` and `update` actions accept the public attributes being added. + * `--uuid-primary-key` or `-u` - Adds a UUIDv4 primary key with that name. i.e `-u id` + * `--uuid-v7-primary-key` - Adds a UUIDv7 primary key with that name. + * `--integer-primary-key` or `-i` - Adds an integer primary key with that name. i.e `-i id` + * `--domain` or `-d` - The domain module to add the resource to. i.e `-d MyApp.MyDomain`. This defaults to the resource's module name, minus the last segment. + * `--extend` or `-e` - A comma separated list of modules or builtins to extend the resource with. i.e `-e postgres,Some.Extension` + * `--base` or `-b` - The base module to use for the resource. i.e `-b Ash.Resource`. Requires that the module is in `config :your_app, :base_resources` + * `--timestamps` or `-t` - If set adds `inserted_at` and `updated_at` timestamps to the resource. + """ + + @shortdoc "Generate and configure an Ash.Resource." + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(argv, _parent) do + for {key, cmd} <- [da: "--default-actions", u7: "--uuid-v7-primary-key"] do + if "-#{key}" in argv do + Mix.shell().error(""" + The `-#{key}` alias has been removed as multi-char aliases are deprecated in OptionParser. + Please use `--#{cmd}` instead. + """) + + Mix.shell().exit({:shutdown, 1}) + end end - end - %Igniter.Mix.Task.Info{ - positional: [:resource], - example: @example, - schema: [ - attribute: :csv, - relationship: :csv, - default_actions: :csv, - uuid_primary_key: :string, - uuid_v7_primary_key: :string, - integer_primary_key: :string, - domain: :string, - extend: :csv, - base: :string, - timestamps: :boolean, - da: :string, - u7: :string - ], - aliases: [ - a: :attribute, - r: :relationship, - d: :domain, - u: :uuid_primary_key, - i: :integer_primary_key, - e: :extend, - b: :base, - t: :timestamps - ] - } - end + %Igniter.Mix.Task.Info{ + positional: [:resource], + example: @example, + schema: [ + attribute: :csv, + relationship: :csv, + default_actions: :csv, + uuid_primary_key: :string, + uuid_v7_primary_key: :string, + integer_primary_key: :string, + domain: :string, + extend: :csv, + base: :string, + timestamps: :boolean, + da: :string, + u7: :string + ], + aliases: [ + a: :attribute, + r: :relationship, + d: :domain, + u: :uuid_primary_key, + i: :integer_primary_key, + e: :extend, + b: :base, + t: :timestamps + ] + } + end - @impl Igniter.Mix.Task - def igniter(igniter) do - arguments = igniter.args.positional - options = igniter.args.options - argv = igniter.args.argv_flags - - resource = Igniter.Project.Module.parse(arguments.resource) - app_name = Igniter.Project.Application.app_name(igniter) - - domain = - case options[:domain] do - nil -> - resource - |> Module.split() - |> :lists.droplast() - |> Module.concat() - - domain -> - Igniter.Project.Module.parse(domain) - end + @impl Igniter.Mix.Task + def igniter(igniter) do + arguments = igniter.args.positional + options = igniter.args.options + argv = igniter.args.argv_flags + + resource = Igniter.Project.Module.parse(arguments.resource) + app_name = Igniter.Project.Application.app_name(igniter) + + domain = + case options[:domain] do + nil -> + resource + |> Module.split() + |> :lists.droplast() + |> Module.concat() + + domain -> + Igniter.Project.Module.parse(domain) + end - options = - options - |> Keyword.update( - :default_actions, - [], - fn defaults -> Enum.sort_by(defaults, &(&1 in ["create", "update"])) end - ) - |> Keyword.put_new(:base, "Ash.Resource") + options = + options + |> Keyword.update( + :default_actions, + [], + fn defaults -> Enum.sort_by(defaults, &(&1 in ["create", "update"])) end + ) + |> Keyword.put_new(:base, "Ash.Resource") + + base = + if options[:base] == "Ash.Resource" do + "Ash.Resource" + else + base = + Igniter.Project.Module.parse(options[:base]) - base = - if options[:base] == "Ash.Resource" do - "Ash.Resource" - else - base = - Igniter.Project.Module.parse(options[:base]) + unless base in List.wrap(Application.get_env(app_name, :base_resources)) do + raise """ + The base module #{inspect(base)} is not in the list of base resources. - unless base in List.wrap(Application.get_env(app_name, :base_resources)) do - raise """ - The base module #{inspect(base)} is not in the list of base resources. + If it exists but is not in the base resource list, add it like so: - If it exists but is not in the base resource list, add it like so: + `config #{inspect(app_name)}, base_resources: [#{inspect(base)}]` - `config #{inspect(app_name)}, base_resources: [#{inspect(base)}]` + If it does not exist, you can generate a base resource with `mix ash.gen.base_resource #{inspect(base)}` + """ + end - If it does not exist, you can generate a base resource with `mix ash.gen.base_resource #{inspect(base)}` - """ + inspect(base) end - inspect(base) - end + attributes = attributes(options) - attributes = attributes(options) - - relationships = - if !Enum.empty?(options[:relationship]) do - """ - relationships do - #{relationships(options)} + relationships = + if !Enum.empty?(options[:relationship]) do + """ + relationships do + #{relationships(options)} + end + """ end - """ - end - default_accept = - Enum.flat_map(options[:attribute], fn attribute -> - [name, _type | modifiers] = String.split(attribute, ":", trim: true) + default_accept = + Enum.flat_map(options[:attribute], fn attribute -> + [name, _type | modifiers] = String.split(attribute, ":", trim: true) - if "public" in modifiers do - [String.to_atom(name)] - else - [] + if "public" in modifiers do + [String.to_atom(name)] + else + [] + end + end) + + actions = + case options[:default_actions] do + [] -> + "" + + defaults -> + default_contents = + Enum.map_join(defaults, ", ", fn + type when type in ["read", "destroy"] -> + ":#{type}" + + type when type in ["create", "update"] -> + "#{type}: #{inspect(default_accept)}" + + type -> + raise """ + Invalid default action type given to `--default-actions`: #{inspect(type)}. + """ + end) + + """ + actions do + defaults [#{default_contents}] + end + """ end - end) - - actions = - case options[:default_actions] do - [] -> - "" - defaults -> - default_contents = - Enum.map_join(defaults, ", ", fn - type when type in ["read", "destroy"] -> - ":#{type}" - - type when type in ["create", "update"] -> - "#{type}: #{inspect(default_accept)}" - - type -> - raise """ - Invalid default action type given to `--default-actions`: #{inspect(type)}. - """ - end) + attributes = + if options[:uuid_primary_key] || options[:integer_primary_key] || + options[:uuid_v7_primary_key] || + !Enum.empty?(options[:attribute]) || options[:timestamps] do + uuid_primary_key = + if options[:uuid_primary_key] do + pkey_builder("uuid_primary_key", options[:uuid_primary_key]) + end + + uuid_v7_primary_key = + if options[:uuid_v7_primary_key] do + pkey_builder("uuid_v7_primary_key", options[:uuid_v7_primary_key]) + end + + integer_primary_key = + if options[:integer_primary_key] do + pkey_builder("integer_primary_key", options[:integer_primary_key]) + end + + timestamps = + if options[:timestamps] do + "timestamps()" + end """ - actions do - defaults [#{default_contents}] + attributes do + #{uuid_primary_key} + #{uuid_v7_primary_key} + #{integer_primary_key} + #{attributes} + #{timestamps} end """ - end + end - attributes = - if options[:uuid_primary_key] || options[:integer_primary_key] || - options[:uuid_v7_primary_key] || - !Enum.empty?(options[:attribute]) || options[:timestamps] do - uuid_primary_key = - if options[:uuid_primary_key] do - pkey_builder("uuid_primary_key", options[:uuid_primary_key]) - end + igniter + |> Igniter.compose_task("ash.gen.domain", [inspect(domain), "--ignore-if-exists"]) + |> Ash.Domain.Igniter.add_resource_reference( + domain, + resource + ) + |> Igniter.Project.Module.create_module( + resource, + """ + use #{base}, + otp_app: #{inspect(app_name)}, + domain: #{inspect(domain)} - uuid_v7_primary_key = - if options[:uuid_v7_primary_key] do - pkey_builder("uuid_v7_primary_key", options[:uuid_v7_primary_key]) - end + #{actions} - integer_primary_key = - if options[:integer_primary_key] do - pkey_builder("integer_primary_key", options[:integer_primary_key]) - end + #{attributes} - timestamps = - if options[:timestamps] do - "timestamps()" - end + #{relationships} + """ + ) + |> extend(resource, options[:extend], argv) + end + + defp extend(igniter, _, [], _) do + igniter + end + defp extend(igniter, resource, extensions, argv) do + Igniter.compose_task( + igniter, + "ash.patch.extend", + [inspect(resource), Enum.join(extensions, ",")] ++ argv + ) + end + + defp pkey_builder(builder, text) do + [name | modifiers] = String.split(text, ":", trim: true) + modifiers = modifiers -- ["primary_key"] + + if Enum.empty?(modifiers) do + "#{builder} :#{name}" + else """ - attributes do - #{uuid_primary_key} - #{uuid_v7_primary_key} - #{integer_primary_key} - #{attributes} - #{timestamps} + #{builder} :#{name} do + #{attribute_modifier_string(modifiers)} end """ end + end - igniter - |> Igniter.compose_task("ash.gen.domain", [inspect(domain), "--ignore-if-exists"]) - |> Ash.Domain.Igniter.add_resource_reference( - domain, - resource - ) - |> Igniter.Project.Module.create_module( - resource, - """ - use #{base}, - otp_app: #{inspect(app_name)}, - domain: #{inspect(domain)} - - #{actions} - - #{attributes} - - #{relationships} - """ - ) - |> extend(resource, options[:extend], argv) - end + defp attributes(options) do + options[:attribute] + |> List.wrap() + |> Enum.join(",") + |> String.split(",", trim: true) + |> Enum.map(fn attribute -> + case String.split(attribute, ":") do + [name, type | modifiers] -> + {name, type, modifiers} + + _name -> + raise """ + Invalid attribute format: #{attribute}. Please use the format `name:type` for each attribute. + """ + end + end) + |> Enum.map_join("\n", fn + {name, type, []} -> + type = resolve_type(type) - defp extend(igniter, _, [], _) do - igniter - end + "attribute :#{name}, #{inspect(type)}" - defp extend(igniter, resource, extensions, argv) do - Igniter.compose_task( - igniter, - "ash.patch.extend", - [inspect(resource), Enum.join(extensions, ",")] ++ argv - ) - end + {name, type, modifiers} -> + modifier_string = attribute_modifier_string(modifiers) - defp pkey_builder(builder, text) do - [name | modifiers] = String.split(text, ":", trim: true) - modifiers = modifiers -- ["primary_key"] + type = resolve_type(type) - if Enum.empty?(modifiers) do - "#{builder} :#{name}" - else - """ - #{builder} :#{name} do - #{attribute_modifier_string(modifiers)} - end - """ + """ + attribute :#{name}, #{inspect(type)} do + #{modifier_string} + end + """ + end) end - end - defp attributes(options) do - options[:attribute] - |> List.wrap() - |> Enum.join(",") - |> String.split(",", trim: true) - |> Enum.map(fn attribute -> - case String.split(attribute, ":") do - [name, type | modifiers] -> - {name, type, modifiers} - - _name -> - raise """ - Invalid attribute format: #{attribute}. Please use the format `name:type` for each attribute. - """ - end - end) - |> Enum.map_join("\n", fn - {name, type, []} -> - type = resolve_type(type) + defp attribute_modifier_string(modifiers) do + modifiers + |> Enum.uniq() + |> Enum.map_join("\n", fn + "primary_key" -> + "primary_key? true" - "attribute :#{name}, #{inspect(type)}" + "public" -> + "public? true" - {name, type, modifiers} -> - modifier_string = attribute_modifier_string(modifiers) + "required" -> + "allow_nil? false" - type = resolve_type(type) + "sensitive" -> + "sensitive? true" - """ - attribute :#{name}, #{inspect(type)} do - #{modifier_string} - end - """ - end) - end + unknown -> + raise ArgumentError, + """ + Unrecognizeable attribute modifier: `#{unknown}`. - defp attribute_modifier_string(modifiers) do - modifiers - |> Enum.uniq() - |> Enum.map_join("\n", fn - "primary_key" -> - "primary_key? true" + Known modifiers are: primary_key, public, required, sensitive. + """ + end) + end - "public" -> - "public? true" + defp relationships(options) do + options[:relationship] + |> List.wrap() + |> Enum.join(",") + |> String.split(",") + |> Enum.map(fn relationship -> + case String.split(relationship, ":") do + [type, name, destination | modifiers] -> + {type, name, destination, modifiers} + + _name -> + raise """ + Invalid relationship format: #{relationship}. Please use the format `type:name:destination` for each attribute. + """ + end + end) + |> Enum.map_join("\n", fn + {type, name, destination, []} -> + "#{type} :#{name}, #{destination}" + + {type, name, destination, modifiers} -> + modifier_string = + Enum.map_join(modifiers, "\n", fn + "primary_key" -> + if type == "belongs_to" do + "primary_key? true" + else + raise ArgumentError, + "The @ modifier (for `primary_key?: true`) is only valid for belongs_to relationships, saw it in `#{type}:#{name}`" + end + + "public" -> + "public? true" + + "sensitive?" -> + "sensitive? true" + + "required" -> + if type == "belongs_to" do + "allow_nil? false" + else + raise ArgumentError, + "The ! modifier (for `allow_nil?: false`) is only valid for belongs_to relationships, saw it in `#{type}:#{name}`" + end + end) - "required" -> - "allow_nil? false" + """ + #{type} :#{name}, #{destination} do + #{modifier_string} + end + """ + end) + end - "sensitive" -> - "sensitive? true" + defp resolve_type(value) do + resolved_type = + if String.contains?(value, ".") do + Module.concat([value]) + else + String.to_atom(value) + end - unknown -> - raise ArgumentError, - """ - Unrecognizeable attribute modifier: `#{unknown}`. + ensure_ash_type!(resolved_type) + + resolved_type + end - Known modifiers are: primary_key, public, required, sensitive. - """ - end) + defp ensure_ash_type!(original_type) do + _ = Ash.Type.get_type!(original_type) + end end +else + defmodule Mix.Tasks.Ash.Gen.Resource do + @moduledoc """ + Generate and configure an Ash.Resource. + """ - defp relationships(options) do - options[:relationship] - |> List.wrap() - |> Enum.join(",") - |> String.split(",") - |> Enum.map(fn relationship -> - case String.split(relationship, ":") do - [type, name, destination | modifiers] -> - {type, name, destination, modifiers} - - _name -> - raise """ - Invalid relationship format: #{relationship}. Please use the format `type:name:destination` for each attribute. - """ - end - end) - |> Enum.map_join("\n", fn - {type, name, destination, []} -> - "#{type} :#{name}, #{destination}" - - {type, name, destination, modifiers} -> - modifier_string = - Enum.map_join(modifiers, "\n", fn - "primary_key" -> - if type == "belongs_to" do - "primary_key? true" - else - raise ArgumentError, - "The @ modifier (for `primary_key?: true`) is only valid for belongs_to relationships, saw it in `#{type}:#{name}`" - end - - "public" -> - "public? true" - - "sensitive?" -> - "sensitive? true" - - "required" -> - if type == "belongs_to" do - "allow_nil? false" - else - raise ArgumentError, - "The ! modifier (for `allow_nil?: false`) is only valid for belongs_to relationships, saw it in `#{type}:#{name}`" - end - end) + @shortdoc "Generate and configure an Ash.Resource." - """ - #{type} :#{name}, #{destination} do - #{modifier_string} - end - """ - end) - end + use Mix.Task - defp resolve_type(value) do - resolved_type = - if String.contains?(value, ".") do - Module.concat([value]) - else - String.to_atom(value) - end + def run(_argv) do + Mix.shell().error(""" + The task 'ash.gen.resource' requires igniter to be run. - ensure_ash_type!(resolved_type) + Please install igniter and try again. - resolved_type - end + For more information, see: https://hexdocs.pm/igniter + """) - defp ensure_ash_type!(original_type) do - _ = Ash.Type.get_type!(original_type) + exit({:shutdown, 1}) + end end end diff --git a/lib/mix/tasks/install/ash.install.ex b/lib/mix/tasks/install/ash.install.ex index 71ffd3254..df6f208d8 100644 --- a/lib/mix/tasks/install/ash.install.ex +++ b/lib/mix/tasks/install/ash.install.ex @@ -1,167 +1,189 @@ -defmodule Mix.Tasks.Ash.Install do - @moduledoc "Installs Ash into a project. Should be called with `mix igniter.install ash`" - - @shortdoc @moduledoc - use Igniter.Mix.Task - - # I know for a fact that this will spark lots of conversation, debate and bike shedding. - # I will direct everyone who wants to debate about it here, and that will be all. - # - # Number of people who wanted this to be different: 0 - @resource_default_section_order [ - :resource, - :code_interface, - :actions, - :policies, - :pub_sub, - :preparations, - :changes, - :validations, - :multitenancy, - :attributes, - :relationships, - :calculations, - :aggregates, - :identities - ] - - @domain_default_section_order [ - :resources, - :policies, - :authorization, - :domain, - :execution - ] - - @impl Igniter.Mix.Task - def info(_argv, _source) do - %Igniter.Mix.Task.Info{ - composes: ["spark.install", "ash.gen.resource"] - } - end +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Ash.Install do + @moduledoc "Installs Ash into a project. Should be called with `mix igniter.install ash`" - @impl Igniter.Mix.Task - def igniter(igniter) do - igniter - |> Igniter.compose_task("spark.install") - |> Igniter.Project.Formatter.import_dep(:ash) - |> Spark.Igniter.prepend_to_section_order( - :"Ash.Resource", - @resource_default_section_order - ) - |> Spark.Igniter.prepend_to_section_order( - :"Ash.Domain", - @domain_default_section_order - ) - |> Igniter.Project.Config.configure( - "config.exs", - :ash, - [:include_embedded_source_by_default?], - false - ) - |> Igniter.Project.Config.configure( - "config.exs", - :ash, - [:show_keysets_for_all_actions?], - false - ) - |> Igniter.Project.Config.configure( - "config.exs", - :ash, - [:default_page_type], - :keyset - ) - |> Igniter.Project.Config.configure( - "config.exs", - :ash, - [:policies, :no_filter_static_forbidden_reads?], - false - ) - |> then(fn igniter -> - if "--example" in igniter.args.argv_flags do - generate_example(igniter, igniter.args.argv_flags) - else - igniter - end - end) - end + @shortdoc @moduledoc + use Igniter.Mix.Task + + # I know for a fact that this will spark lots of conversation, debate and bike shedding. + # I will direct everyone who wants to debate about it here, and that will be all. + # + # Number of people who wanted this to be different: 0 + @resource_default_section_order [ + :resource, + :code_interface, + :actions, + :policies, + :pub_sub, + :preparations, + :changes, + :validations, + :multitenancy, + :attributes, + :relationships, + :calculations, + :aggregates, + :identities + ] + + @domain_default_section_order [ + :resources, + :policies, + :authorization, + :domain, + :execution + ] - defp generate_example(igniter, argv) do - domain_module_name = Igniter.Project.Module.module_name(igniter, "Support") - ticket_resource = Igniter.Project.Module.module_name(igniter, "Support.Ticket") - - representative_resource = - Igniter.Project.Module.module_name(igniter, "Support.Representative") - - ticket_status_module_name = - Igniter.Project.Module.module_name(igniter, "Support.Ticket.Types.Status") - - igniter - |> Igniter.compose_task("ash.gen.domain", [inspect(domain_module_name)]) - |> Igniter.compose_task("ash.gen.enum", [ - inspect(ticket_status_module_name), - "open,closed", - "--short-name", - "ticket_status" - ]) - |> Igniter.compose_task( - "ash.gen.resource", - [ - inspect(ticket_resource), - "--domain", - inspect(domain_module_name), - "--default-actions", - "read", - "--uuid-primary-key", - "id", - "--attribute", - "subject:string:required:public", - "--relationship", - "belongs_to:representative:#{inspect(representative_resource)}:public" - ] ++ argv - ) - |> Igniter.compose_task( - "ash.gen.resource", - [ - inspect(representative_resource), - "--domain", - inspect(domain_module_name), - "--default-actions", - "read,create", - "--uuid-primary-key", - "id", - "--attribute", - "name:string:required:public", - "--relationship", - "has_many:tickets:#{inspect(ticket_resource)}:public" - ] ++ argv - ) - |> Ash.Resource.Igniter.add_attribute(ticket_resource, """ - attribute :status, :ticket_status do - default :open - allow_nil? false + @impl Igniter.Mix.Task + def info(_argv, _source) do + %Igniter.Mix.Task.Info{ + composes: ["spark.install", "ash.gen.resource"] + } end - """) - |> Ash.Resource.Igniter.add_action(ticket_resource, """ - create :open do - accept [:subject] + + @impl Igniter.Mix.Task + def igniter(igniter) do + igniter + |> Igniter.compose_task("spark.install") + |> Igniter.Project.Formatter.import_dep(:ash) + |> Spark.Igniter.prepend_to_section_order( + :"Ash.Resource", + @resource_default_section_order + ) + |> Spark.Igniter.prepend_to_section_order( + :"Ash.Domain", + @domain_default_section_order + ) + |> Igniter.Project.Config.configure( + "config.exs", + :ash, + [:include_embedded_source_by_default?], + false + ) + |> Igniter.Project.Config.configure( + "config.exs", + :ash, + [:show_keysets_for_all_actions?], + false + ) + |> Igniter.Project.Config.configure( + "config.exs", + :ash, + [:default_page_type], + :keyset + ) + |> Igniter.Project.Config.configure( + "config.exs", + :ash, + [:policies, :no_filter_static_forbidden_reads?], + false + ) + |> then(fn igniter -> + if "--example" in igniter.args.argv_flags do + generate_example(igniter, igniter.args.argv_flags) + else + igniter + end + end) end - """) - |> Ash.Resource.Igniter.add_action(ticket_resource, """ - update :close do - accept [] - validate attribute_does_not_equal(:status, :closed) do - message "Ticket is already closed" + defp generate_example(igniter, argv) do + domain_module_name = Igniter.Project.Module.module_name(igniter, "Support") + ticket_resource = Igniter.Project.Module.module_name(igniter, "Support.Ticket") + + representative_resource = + Igniter.Project.Module.module_name(igniter, "Support.Representative") + + ticket_status_module_name = + Igniter.Project.Module.module_name(igniter, "Support.Ticket.Types.Status") + + igniter + |> Igniter.compose_task("ash.gen.domain", [inspect(domain_module_name)]) + |> Igniter.compose_task("ash.gen.enum", [ + inspect(ticket_status_module_name), + "open,closed", + "--short-name", + "ticket_status" + ]) + |> Igniter.compose_task( + "ash.gen.resource", + [ + inspect(ticket_resource), + "--domain", + inspect(domain_module_name), + "--default-actions", + "read", + "--uuid-primary-key", + "id", + "--attribute", + "subject:string:required:public", + "--relationship", + "belongs_to:representative:#{inspect(representative_resource)}:public" + ] ++ argv + ) + |> Igniter.compose_task( + "ash.gen.resource", + [ + inspect(representative_resource), + "--domain", + inspect(domain_module_name), + "--default-actions", + "read,create", + "--uuid-primary-key", + "id", + "--attribute", + "name:string:required:public", + "--relationship", + "has_many:tickets:#{inspect(ticket_resource)}:public" + ] ++ argv + ) + |> Ash.Resource.Igniter.add_attribute(ticket_resource, """ + attribute :status, :ticket_status do + default :open + allow_nil? false + end + """) + |> Ash.Resource.Igniter.add_action(ticket_resource, """ + create :open do + accept [:subject] end + """) + |> Ash.Resource.Igniter.add_action(ticket_resource, """ + update :close do + accept [] - change set_attribute(:status, :closed) + validate attribute_does_not_equal(:status, :closed) do + message "Ticket is already closed" + end + + change set_attribute(:status, :closed) + end + """) + |> Ash.Resource.Igniter.add_action(ticket_resource, """ + update :assign do + accept [:representative_id] + end + """) end - """) - |> Ash.Resource.Igniter.add_action(ticket_resource, """ - update :assign do - accept [:representative_id] + end +else + defmodule Mix.Tasks.Ash.Install do + @moduledoc "Installs Ash into a project. Should be called with `mix igniter.install ash`" + + @shortdoc @moduledoc + + use Mix.Task + + def run(_argv) do + Mix.shell().error(""" + The task 'ash.install' requires igniter to be run. + + Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter + """) + + exit({:shutdown, 1}) end - """) end end diff --git a/lib/mix/tasks/patch/ash.patch.extend.ex b/lib/mix/tasks/patch/ash.patch.extend.ex index d64c0e426..a68a41bed 100644 --- a/lib/mix/tasks/patch/ash.patch.extend.ex +++ b/lib/mix/tasks/patch/ash.patch.extend.ex @@ -1,272 +1,302 @@ -defmodule Mix.Tasks.Ash.Patch.Extend do - @example "mix ash.patch.extend My.Domain.Resource postgres,Ash.Policy.Authorizer" - @moduledoc """ - Adds an extension or extensions to the domain/resource - - Extensions can either be a fully qualified module name, or one of the following list, based on the thing being extended - - ### Ash.Domain - - - `json_api` - `AshJsonApi.Domain` - - `graphql` - `AshGraphql.Domain` - - ### Ash.Resource - - - `postgres` - `AshPostgres.DataLayer` - - `sqlite` - `AshSqlite.DataLayer` - - `mysql` - `AshMysql.DataLayer` - - `ets` - `Ash.DataLayer.Ets` - - `mnesia` - `Ash.DataLayer.Mnesia` - - `embedded` - `data_layer: :embedded` - - `json_api` - `AshJsonApi.Resource` - - `graphql` - `AshGraphql.Resource` - - ## Example - - ```bash - #{@example} - ``` - """ - @shortdoc "Adds an extension or extensions to the given domain/resource" - require Igniter.Code.Common - use Igniter.Mix.Task - - @impl Igniter.Mix.Task - def info(_argv, _parent) do - %Igniter.Mix.Task.Info{ - positional: [ - :subject, - extensions: [ - rest: true +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Ash.Patch.Extend do + @example "mix ash.patch.extend My.Domain.Resource postgres,Ash.Policy.Authorizer" + @moduledoc """ + Adds an extension or extensions to the domain/resource + + Extensions can either be a fully qualified module name, or one of the following list, based on the thing being extended + + ### Ash.Domain + + - `json_api` - `AshJsonApi.Domain` + - `graphql` - `AshGraphql.Domain` + + ### Ash.Resource + + - `postgres` - `AshPostgres.DataLayer` + - `sqlite` - `AshSqlite.DataLayer` + - `mysql` - `AshMysql.DataLayer` + - `ets` - `Ash.DataLayer.Ets` + - `mnesia` - `Ash.DataLayer.Mnesia` + - `embedded` - `data_layer: :embedded` + - `json_api` - `AshJsonApi.Resource` + - `graphql` - `AshGraphql.Resource` + + ## Example + + ```bash + #{@example} + ``` + """ + @shortdoc "Adds an extension or extensions to the given domain/resource" + require Igniter.Code.Common + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _parent) do + %Igniter.Mix.Task.Info{ + positional: [ + :subject, + extensions: [ + rest: true + ] + ], + example: @example + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + Mix.Task.run("compile") + subject = igniter.args.positional.subject + extensions = igniter.args.positional.extensions + + opts = + [ + subjects: String.split(subject, ",", trim: true), + extensions: String.split(Enum.join(extensions, ","), ",", trim: true) ] - ], - example: @example - } - end - @impl Igniter.Mix.Task - def igniter(igniter) do - Mix.Task.run("compile") - subject = igniter.args.positional.subject - extensions = igniter.args.positional.extensions - - opts = - [ - subjects: String.split(subject, ",", trim: true), - extensions: String.split(Enum.join(extensions, ","), ",", trim: true) - ] - - extensions = opts[:extensions] - - Enum.reduce(opts[:subjects], igniter, fn subject, igniter -> - subject = Igniter.Project.Module.parse(subject) - - case Igniter.Project.Module.find_module(igniter, subject) do - {:error, igniter} -> - Igniter.add_issue(igniter, "Could not find module to extend: #{subject}") - - {:ok, {igniter, source, zipper}} -> - case kind_of_thing(zipper) do - {:ok, kind_of_thing} -> - {igniter, patchers, _install} = - Enum.reduce(extensions, {igniter, [], []}, fn extension, - {igniter, patchers, install} -> - case patcher( - kind_of_thing, - subject, - extension, - source.path, - igniter.args.argv_flags - ) do - {fun, new_install} when is_function(fun, 1) -> - {igniter, [fun | patchers], install ++ new_install} - - {:error, error} -> - {Igniter.add_issue(igniter, error), patchers, install} - end + extensions = opts[:extensions] + + Enum.reduce(opts[:subjects], igniter, fn subject, igniter -> + subject = Igniter.Project.Module.parse(subject) + + case Igniter.Project.Module.find_module(igniter, subject) do + {:error, igniter} -> + Igniter.add_issue(igniter, "Could not find module to extend: #{subject}") + + {:ok, {igniter, source, zipper}} -> + case kind_of_thing(zipper) do + {:ok, kind_of_thing} -> + {igniter, patchers, _install} = + Enum.reduce(extensions, {igniter, [], []}, fn extension, + {igniter, patchers, install} -> + case patcher( + kind_of_thing, + subject, + extension, + source.path, + igniter.args.argv_flags + ) do + {fun, new_install} when is_function(fun, 1) -> + {igniter, [fun | patchers], install ++ new_install} + + {:error, error} -> + {Igniter.add_issue(igniter, error), patchers, install} + end + end) + + Enum.reduce(patchers, igniter, fn patcher, igniter -> + patcher.(igniter) end) - Enum.reduce(patchers, igniter, fn patcher, igniter -> - patcher.(igniter) - end) + :error -> + Igniter.add_issue( + igniter, + "Could not determine whether #{subject} is an `Ash.Resource` or an `Ash.Domain`." + ) + end + end + end) + end - :error -> - Igniter.add_issue( - igniter, - "Could not determine whether #{subject} is an `Ash.Resource` or an `Ash.Domain`." - ) + defp kind_of_thing(zipper) do + case Igniter.Code.Common.move_to_do_block(zipper) do + {:ok, zipper} -> + with {_, :error} <- + {Ash.Resource, Igniter.Code.Module.move_to_use(zipper, Ash.Resource)}, + {_, :error} <- + {Ash.Domain, Igniter.Code.Module.move_to_use(zipper, Ash.Domain)} do + :error + else + {kind_of_thing, {:ok, _}} -> + {:ok, kind_of_thing} end - end - end) - end - defp kind_of_thing(zipper) do - case Igniter.Code.Common.move_to_do_block(zipper) do - {:ok, zipper} -> - with {_, :error} <- - {Ash.Resource, Igniter.Code.Module.move_to_use(zipper, Ash.Resource)}, - {_, :error} <- - {Ash.Domain, Igniter.Code.Module.move_to_use(zipper, Ash.Domain)} do + _ -> :error - else - {kind_of_thing, {:ok, _}} -> - {:ok, kind_of_thing} - end - - _ -> - :error + end end - end - defp patcher(kind_of_thing, module, extension, path, argv) do - original_request = extension + defp patcher(kind_of_thing, module, extension, path, argv) do + original_request = extension - {install, extension} = - case {kind_of_thing, String.trim_leading(String.downcase(extension), "ash_"), extension} do - {Ash.Resource, "postgres", _} -> - {[:ash_postgres], AshPostgres.DataLayer} + {install, extension} = + case {kind_of_thing, String.trim_leading(String.downcase(extension), "ash_"), extension} do + {Ash.Resource, "postgres", _} -> + {[:ash_postgres], AshPostgres.DataLayer} - {Ash.Resource, "sqlite", _} -> - {[:ash_sqlite], AshSqlite.DataLayer} + {Ash.Resource, "sqlite", _} -> + {[:ash_sqlite], AshSqlite.DataLayer} - {Ash.Resource, "mysql", _} -> - {[:mysql], AshMysql.DataLayer} + {Ash.Resource, "mysql", _} -> + {[:mysql], AshMysql.DataLayer} - {Ash.Resource, "ets", _} -> - {[], Ash.DataLayer.Ets} + {Ash.Resource, "ets", _} -> + {[], Ash.DataLayer.Ets} - {Ash.Resource, "mnesia", _} -> - {[], Ash.DataLayer.Mnesia} + {Ash.Resource, "mnesia", _} -> + {[], Ash.DataLayer.Mnesia} - {Ash.Resource, "embedded", _} -> - {[], &embedded_patcher(&1, module)} + {Ash.Resource, "embedded", _} -> + {[], &embedded_patcher(&1, module)} - {Ash.Resource, "json_api", _} -> - {[:ash_json_api], AshJsonApi.Resource} + {Ash.Resource, "json_api", _} -> + {[:ash_json_api], AshJsonApi.Resource} - {Ash.Resource, "graphql", _} -> - {[:ash_graphql], AshGraphql.Resource} + {Ash.Resource, "graphql", _} -> + {[:ash_graphql], AshGraphql.Resource} - {Ash.Domain, "json_api", _} -> - {[:ash_json_api], AshJsonApi.Domain} + {Ash.Domain, "json_api", _} -> + {[:ash_json_api], AshJsonApi.Domain} - {Ash.Domain, "graphql", _} -> - {[:ash_graphql], AshGraphql.Domain} + {Ash.Domain, "graphql", _} -> + {[:ash_graphql], AshGraphql.Domain} - {_kind_of_thing, _, extension} -> - {[], extension} - end + {_kind_of_thing, _, extension} -> + {[], extension} + end - if is_function(extension) do - {extension, install} - else - extension = Module.concat([extension]) - - if Code.ensure_loaded?(extension) do - fun = - if function_exported?(extension, :install, 5) do - fn igniter -> - extension.install(igniter, module, kind_of_thing, path, argv) - |> simple_add_extension(kind_of_thing, module, extension) + if is_function(extension) do + {extension, install} + else + extension = Module.concat([extension]) + + if Code.ensure_loaded?(extension) do + fun = + if function_exported?(extension, :install, 5) do + fn igniter -> + extension.install(igniter, module, kind_of_thing, path, argv) + |> simple_add_extension(kind_of_thing, module, extension) + end + else + &simple_add_extension(&1, kind_of_thing, module, extension) end - else - &simple_add_extension(&1, kind_of_thing, module, extension) - end - {fun, install} - else - extensions = Enum.map(Ash.Mix.Tasks.Helpers.extensions!([]), &inspect/1) - - short_codes = [ - {AshJsonApi.Resource, "json_api"}, - {AshPostgres.DataLayer, "postgres"}, - {AshGraphql.Resource, "graphql"}, - {AshMySql.DataLayer, "mysql"}, - {AshSqlite.DataLayer, "sqlite"}, - "ets", - "mnesia", - "embedded" - ] + {fun, install} + else + extensions = Enum.map(Ash.Mix.Tasks.Helpers.extensions!([]), &inspect/1) + + short_codes = [ + {AshJsonApi.Resource, "json_api"}, + {AshPostgres.DataLayer, "postgres"}, + {AshGraphql.Resource, "graphql"}, + {AshMySql.DataLayer, "mysql"}, + {AshSqlite.DataLayer, "sqlite"}, + "ets", + "mnesia", + "embedded" + ] + + installable = + short_codes + |> Enum.concat(extensions) + |> Enum.flat_map(fn + {dependency, name} -> + if Code.ensure_loaded?(dependency) do + [" * #{name}"] + else + [] + end + + dependency -> + [" * #{dependency}"] + end) + |> Enum.join("\n") + + {:error, + """ + Could not find extension #{original_request}. + + Possible values for extensions + + #{installable} + """} + end + end + end - installable = - short_codes - |> Enum.concat(extensions) - |> Enum.flat_map(fn - {dependency, name} -> - if Code.ensure_loaded?(dependency) do - [" * #{name}"] - else - [] - end + defp embedded_patcher(igniter, resource) do + domain = + resource + |> Module.split() + |> :lists.droplast() + |> Module.concat() + + igniter + |> remove_domain_option(resource) + |> Spark.Igniter.add_extension(resource, Ash.Resource, :data_layer, :embedded, true) + |> Ash.Domain.Igniter.remove_resource_reference(domain, resource) + |> Spark.Igniter.update_dsl( + resource, + [{:section, :actions}, {:option, :defaults}], + [:read, :destroy, create: :*, update: :*], + fn x -> {:ok, x} end + ) + end + + defp remove_domain_option(igniter, module) do + Igniter.Project.Module.find_and_update_module!(igniter, module, fn zipper -> + with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ash.Resource), + {:ok, zipper} <- + Igniter.Code.Function.update_nth_argument(zipper, 1, fn values_zipper -> + Igniter.Code.Keyword.remove_keyword_key(values_zipper, :domain) + end) do + {:ok, zipper} + else + _ -> + {:ok, zipper} + end + end) + end - dependency -> - [" * #{dependency}"] - end) - |> Enum.join("\n") + defp simple_add_extension(igniter, Ash.Resource, module, extension) do + cond do + Spark.implements_behaviour?(extension, Ash.DataLayer) -> + Spark.Igniter.add_extension(igniter, module, Ash.Resource, :data_layer, extension, true) - {:error, - """ - Could not find extension #{original_request}. + Spark.implements_behaviour?(extension, Ash.Notifier) -> + Spark.Igniter.add_extension(igniter, module, Ash.Resource, :notifiers, extension) - Possible values for extensions + Spark.implements_behaviour?(extension, Ash.Authorizer) -> + Spark.Igniter.add_extension(igniter, module, Ash.Resource, :authorizers, extension) - #{installable} - """} + true -> + Spark.Igniter.add_extension(igniter, module, Ash.Resource, :extensions, extension) end end - end - defp embedded_patcher(igniter, resource) do - domain = - resource - |> Module.split() - |> :lists.droplast() - |> Module.concat() - - igniter - |> remove_domain_option(resource) - |> Spark.Igniter.add_extension(resource, Ash.Resource, :data_layer, :embedded, true) - |> Ash.Domain.Igniter.remove_resource_reference(domain, resource) - |> Spark.Igniter.update_dsl( - resource, - [{:section, :actions}, {:option, :defaults}], - [:read, :destroy, create: :*, update: :*], - fn x -> {:ok, x} end - ) + defp simple_add_extension(igniter, type, module, extension) do + Spark.Igniter.add_extension(igniter, module, type, :extensions, extension) + end end +else + defmodule Mix.Tasks.Ash.Patch.Extend do + @example "mix ash.patch.extend My.Domain.Resource postgres,Ash.Policy.Authorizer" + @moduledoc """ + Adds an extension or extensions to the domain/resource - defp remove_domain_option(igniter, module) do - Igniter.Project.Module.find_and_update_module!(igniter, module, fn zipper -> - with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ash.Resource), - {:ok, zipper} <- - Igniter.Code.Function.update_nth_argument(zipper, 1, fn values_zipper -> - Igniter.Code.Keyword.remove_keyword_key(values_zipper, :domain) - end) do - {:ok, zipper} - else - _ -> - {:ok, zipper} - end - end) - end + ## Example - defp simple_add_extension(igniter, Ash.Resource, module, extension) do - cond do - Spark.implements_behaviour?(extension, Ash.DataLayer) -> - Spark.Igniter.add_extension(igniter, module, Ash.Resource, :data_layer, extension, true) + ```bash + #{@example} + ``` + """ + @shortdoc "Adds an extension or extensions to the given domain/resource" - Spark.implements_behaviour?(extension, Ash.Notifier) -> - Spark.Igniter.add_extension(igniter, module, Ash.Resource, :notifiers, extension) + use Mix.Task - Spark.implements_behaviour?(extension, Ash.Authorizer) -> - Spark.Igniter.add_extension(igniter, module, Ash.Resource, :authorizers, extension) + def run(_argv) do + Mix.shell().error(""" + The task 'ash.patch.extend' requires igniter to be run. - true -> - Spark.Igniter.add_extension(igniter, module, Ash.Resource, :extensions, extension) - end - end + Please install igniter and try again. - defp simple_add_extension(igniter, type, module, extension) do - Spark.Igniter.add_extension(igniter, module, type, :extensions, extension) + For more information, see: https://hexdocs.pm/igniter + """) + + exit({:shutdown, 1}) + end end end diff --git a/mix.exs b/mix.exs index 546505c92..50bbdf66f 100644 --- a/mix.exs +++ b/mix.exs @@ -368,7 +368,7 @@ defmodule Ash.MixProject do {:simple_sat, "~> 0.1 and >= 0.1.1", optional: true}, # Code Generators - {:igniter, "~> 0.4 and >= 0.4.8"}, + {:igniter, "~> 0.4 and >= 0.4.8", optional: true}, # IO Utilities {:owl, "~> 0.11"},