mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 13:03:02 +12:00
improvement: add mix ash.install
improvement: add `mix ash.gen.resource` improvement: add `mix ash.gen.base_resource` improvement: add `mix ash.gen.domain` improvement: add `mix ash.extend`
This commit is contained in:
parent
229887347e
commit
5727cc273f
21 changed files with 1093 additions and 22 deletions
|
@ -1,2 +1,2 @@
|
|||
erlang 26.0.2
|
||||
elixir 1.16.2
|
||||
elixir 1.17.0
|
||||
|
|
|
@ -2125,7 +2125,7 @@ defmodule Ash do
|
|||
## Changes/Validations
|
||||
|
||||
Changes will be applied in the order they are given on the actions as normal. Any change that exposes
|
||||
the `bulk_change` or `bulk_validate` callback will be applied on the entire list.
|
||||
the `bulk_change` callbacks will be applied on the entire list.
|
||||
|
||||
## After Action Hooks
|
||||
|
||||
|
|
|
@ -182,9 +182,6 @@ defmodule Ash.Actions.Read.Calculations do
|
|||
else
|
||||
{:calc, nil} ->
|
||||
{:error, "No such calculation"}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1220,7 +1220,7 @@ defmodule Ash.Actions.Read do
|
|||
calculation.type,
|
||||
list,
|
||||
load_statement,
|
||||
calculation.constraints,
|
||||
calculation.constraints[:items] || [],
|
||||
%{
|
||||
domain: domain,
|
||||
actor: actor,
|
||||
|
|
|
@ -36,6 +36,12 @@ defmodule Ash.Authorizer do
|
|||
|
||||
@optional_callbacks [exception: 2, add_calculations: 3, alter_results: 3, alter_filter: 3]
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
@behaviour Ash.Authorizer
|
||||
end
|
||||
end
|
||||
|
||||
def initial_state(module, actor, resource, action, domain) do
|
||||
module.initial_state(actor, resource, action, domain)
|
||||
end
|
||||
|
|
|
@ -4749,9 +4749,6 @@ defmodule Ash.Changeset do
|
|||
add_invalid_errors(value, :attribute, changeset, attribute, error_or_errors)
|
||||
|> store_casted_attribute(attribute.name, last_val, store_casted?)
|
||||
|
||||
:error ->
|
||||
add_invalid_errors(value, :attribute, changeset, attribute)
|
||||
|
||||
{:error, error_or_errors} ->
|
||||
add_invalid_errors(value, :attribute, changeset, attribute, error_or_errors)
|
||||
end
|
||||
|
|
92
lib/ash/domain/igniter.ex
Normal file
92
lib/ash/domain/igniter.ex
Normal file
|
@ -0,0 +1,92 @@
|
|||
defmodule Ash.Domain.Igniter do
|
||||
@moduledoc "Codemods for working with Ash.Domain modules"
|
||||
|
||||
def add_resource_reference(igniter, domain, resource) do
|
||||
igniter
|
||||
|> Igniter.update_elixir_file(Igniter.Code.Module.proper_location(domain), fn zipper ->
|
||||
case Igniter.Code.Module.move_to_module_using(zipper, Ash.Domain) do
|
||||
:error ->
|
||||
{:error, "Could not find module using Ash.Domain"}
|
||||
|
||||
{:ok, zipper} ->
|
||||
case Igniter.Code.Function.move_to_function_call_in_current_scope(
|
||||
zipper,
|
||||
:resources,
|
||||
1
|
||||
) do
|
||||
:error ->
|
||||
code =
|
||||
"""
|
||||
resources do
|
||||
resource #{inspect(resource)}
|
||||
end
|
||||
"""
|
||||
|
||||
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
|
||||
Igniter.Code.Common.add_code(zipper, "resource #{inspect(resource)}")
|
||||
else
|
||||
_ ->
|
||||
{:ok, zipper}
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def remove_resource_reference(igniter, domain, resource) do
|
||||
igniter
|
||||
|> Igniter.update_elixir_file(Igniter.Code.Module.proper_location(domain), fn zipper ->
|
||||
case Igniter.Code.Module.move_to_module_using(zipper, Ash.Domain) do
|
||||
:error ->
|
||||
{:error, "Could not find module using Ash.Domain"}
|
||||
|
||||
{:ok, 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
|
||||
end
|
|
@ -1,7 +1,5 @@
|
|||
defmodule Ash.Error.Invalid.InvalidPrimaryKey do
|
||||
@moduledoc "Used when an invalid primary key is given to `Ash.get/2`"
|
||||
use Ash.Error.Exception
|
||||
|
||||
use Splode.Error, fields: [:resource, :value], class: :invalid
|
||||
|
||||
def message(%{resource: _resource, value: value}) do
|
||||
|
|
37
lib/ash/extension.ex
Normal file
37
lib/ash/extension.ex
Normal file
|
@ -0,0 +1,37 @@
|
|||
defmodule Ash.Extension do
|
||||
@moduledoc """
|
||||
A behavior of additional callbacks that extensions can implement, specific to Ash.
|
||||
|
||||
It is not necessary to adopt this behavior, but it is recommended to do so if you want to define these
|
||||
functions on your extension. These functions are invoked when their relevant Mix task is run.
|
||||
"""
|
||||
|
||||
@type argv :: [String.t()]
|
||||
|
||||
@type igniter :: Igniter.t()
|
||||
|
||||
@callback migrate(argv) :: term
|
||||
@callback reset(argv) :: term
|
||||
@callback rollback(argv) :: term
|
||||
@callback setup(argv) :: term
|
||||
@callback tear_down(argv) :: term
|
||||
@callback codegen(argv) :: term
|
||||
|
||||
@callback install(
|
||||
igniter,
|
||||
module :: module(),
|
||||
type :: Ash.Resource.t() | Ash.Domain.t(),
|
||||
location :: String.t(),
|
||||
argv
|
||||
) :: igniter
|
||||
|
||||
@optional_callbacks [
|
||||
migrate: 1,
|
||||
reset: 1,
|
||||
rollback: 1,
|
||||
setup: 1,
|
||||
tear_down: 1,
|
||||
codegen: 1,
|
||||
install: 5
|
||||
]
|
||||
end
|
17
lib/ash/igniter.ex
Normal file
17
lib/ash/igniter.ex
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule Ash.Igniter do
|
||||
@moduledoc false
|
||||
def csv_option(options, key, modifier \\ & &1) do
|
||||
Keyword.update(
|
||||
options,
|
||||
key,
|
||||
[],
|
||||
fn defaults ->
|
||||
defaults
|
||||
|> List.wrap()
|
||||
|> Enum.join(",")
|
||||
|> String.split(",")
|
||||
|> then(modifier)
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
|
@ -393,7 +393,7 @@ defmodule Ash.Policy.Authorizer do
|
|||
|
||||
require Logger
|
||||
|
||||
@behaviour Ash.Authorizer
|
||||
use Ash.Authorizer
|
||||
|
||||
@transformers [
|
||||
Ash.Policy.Authorizer.Transformers.AddMissingFieldPolicies,
|
||||
|
|
|
@ -1584,15 +1584,6 @@ defmodule Ash.Query do
|
|||
|
||||
%{query | aggregates: new_aggregates}
|
||||
else
|
||||
%{errors: errors} ->
|
||||
add_error(
|
||||
query,
|
||||
:aggregates,
|
||||
Ash.Error.to_ash_error(errors, nil,
|
||||
bread_crumbs: "Loading aggregate: #{inspect(field)} for query: #{inspect(query)}"
|
||||
)
|
||||
)
|
||||
|
||||
{:error, error} ->
|
||||
add_error(
|
||||
query,
|
||||
|
|
80
lib/ash/resource/igniter.ex
Normal file
80
lib/ash/resource/igniter.ex
Normal file
|
@ -0,0 +1,80 @@
|
|||
defmodule Ash.Resource.Igniter do
|
||||
@moduledoc "Codemods for working with Ash.Resource modules"
|
||||
|
||||
def move_to_resource(zipper) do
|
||||
app_name = Igniter.Project.Application.app_name()
|
||||
|
||||
resources = [
|
||||
Ash.Resource | Application.get_env(app_name, :base_resources) || []
|
||||
]
|
||||
|
||||
case Igniter.Code.Module.move_to_module_using(zipper, resources) do
|
||||
:error ->
|
||||
{:error,
|
||||
"""
|
||||
Could not find module using Ash.Resource or any base resource.
|
||||
|
||||
To configure base resources, use `config :#{app_name}, base_resources: [...]`
|
||||
"""}
|
||||
|
||||
{:ok, zipper} ->
|
||||
{:ok, zipper}
|
||||
end
|
||||
end
|
||||
|
||||
def add_attribute(igniter, resource, attribute) do
|
||||
igniter
|
||||
|> Igniter.update_elixir_file(Igniter.Code.Module.proper_location(resource), fn zipper ->
|
||||
case move_to_resource(zipper) do
|
||||
{:ok, zipper} ->
|
||||
with {:ok, zipper} <-
|
||||
Igniter.Code.Function.move_to_function_call_in_current_scope(
|
||||
zipper,
|
||||
:attributes,
|
||||
1
|
||||
),
|
||||
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
|
||||
Igniter.Code.Common.add_code(zipper, attribute)
|
||||
else
|
||||
_ ->
|
||||
attributes_with_attribute = """
|
||||
attributes do
|
||||
#{attribute}
|
||||
end
|
||||
"""
|
||||
|
||||
Igniter.Code.Common.add_code(zipper, attributes_with_attribute)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def add_action(igniter, resource, action) do
|
||||
igniter
|
||||
|> Igniter.update_elixir_file(Igniter.Code.Module.proper_location(resource), fn zipper ->
|
||||
case move_to_resource(zipper) do
|
||||
{:ok, zipper} ->
|
||||
with {:ok, zipper} <-
|
||||
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, :actions, 1),
|
||||
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
|
||||
Igniter.Code.Common.add_code(zipper, action)
|
||||
else
|
||||
_ ->
|
||||
actions_with_action = """
|
||||
actions do
|
||||
#{action}
|
||||
end
|
||||
"""
|
||||
|
||||
Igniter.Code.Common.add_code(zipper, actions_with_action)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -97,7 +97,7 @@ defmodule Ash.Resource.Transformers.SetPrimaryActions do
|
|||
|
||||
unless type in [:create, :update, :read, :destroy] do
|
||||
raise Spark.Error.DslError,
|
||||
path: [:actions, :default_actions, i],
|
||||
path: [:actions, :defaults, i],
|
||||
message: "#{type} is not a valid action type"
|
||||
end
|
||||
|
||||
|
|
226
lib/mix/tasks/ash.extend.ex
Normal file
226
lib/mix/tasks/ash.extend.ex
Normal file
|
@ -0,0 +1,226 @@
|
|||
defmodule Mix.Tasks.Ash.Extend do
|
||||
@moduledoc """
|
||||
Adds an extension or extensions to the domain/resource
|
||||
|
||||
For example: `mix ash.extend My.Domain.Resource Ash.Policy.Authorizer`
|
||||
"""
|
||||
@shortdoc "Adds an extension or extensions to the given domain/resource"
|
||||
require Igniter.Code.Common
|
||||
use Igniter.Mix.Task
|
||||
|
||||
@impl Igniter.Mix.Task
|
||||
def igniter(igniter, [subject, extensions | argv]) do
|
||||
opts =
|
||||
[
|
||||
subjects: subject,
|
||||
extensions: extensions
|
||||
]
|
||||
|> Ash.Igniter.csv_option(:extensions)
|
||||
|> Ash.Igniter.csv_option(:subjects)
|
||||
|
||||
extensions = opts[:extensions]
|
||||
|
||||
Enum.reduce(opts[:subjects], igniter, fn subject, igniter ->
|
||||
subject = Igniter.Code.Module.parse(subject)
|
||||
path_to_thing = Igniter.Code.Module.proper_location(subject)
|
||||
|
||||
kind_of_thing = kind_of_thing(igniter, path_to_thing)
|
||||
|
||||
# we currently require that the packages required are already installed
|
||||
# probably pretty low hanging fruit to adjust that
|
||||
{igniter, patchers, _install} =
|
||||
Enum.reduce(extensions, {igniter, [], []}, fn extension, {igniter, patchers, install} ->
|
||||
case patcher(kind_of_thing, subject, extension, path_to_thing, argv) 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)
|
||||
|
||||
# unless Enum.empty?(install) do
|
||||
# Mix.Shell.info("""
|
||||
# Before proceeding, we must install the following packages:
|
||||
# """)
|
||||
# Igniter.Install.install(install, argv)
|
||||
# end
|
||||
|
||||
Enum.reduce(patchers, igniter, fn patcher, igniter ->
|
||||
patcher.(igniter)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp kind_of_thing(igniter, path) do
|
||||
igniter = Igniter.include_existing_elixir_file(igniter, path)
|
||||
|
||||
zipper =
|
||||
igniter.rewrite
|
||||
|> Rewrite.source!(path)
|
||||
|> Rewrite.Source.get(:quoted)
|
||||
|> Sourceror.Zipper.zip()
|
||||
|
||||
with {_, :error} <-
|
||||
{Ash.Resource, Igniter.Code.Module.move_to_module_using(zipper, Ash.Resource)},
|
||||
{_, :error} <- {Ash.Domain, Igniter.Code.Module.move_to_module_using(zipper, Ash.Domain)} do
|
||||
raise ArgumentError, """
|
||||
Could not determine whether the thing at #{path} is an `Ash.Resource` or an `Ash.Domain`.
|
||||
|
||||
It is a current limitation of `mix ash.extend` that it requires the module in question to be
|
||||
defined at the "standard" path.
|
||||
|
||||
For example:
|
||||
|
||||
`YourApp.Foo.Bar` -> `lib/your_app/foo/bar.ex`
|
||||
"""
|
||||
else
|
||||
{kind_of_thing, {:ok, _}} ->
|
||||
kind_of_thing
|
||||
end
|
||||
end
|
||||
|
||||
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}
|
||||
|
||||
{Ash.Resource, "sqlite", _} ->
|
||||
{[:ash_sqlite], AshMysql.DataLayer}
|
||||
|
||||
{Ash.Resource, "mysql", _} ->
|
||||
{[:mysql], AshPostgres.DataLayer}
|
||||
|
||||
{Ash.Resource, "ets", _} ->
|
||||
{[], Ash.DataLayer.Ets}
|
||||
|
||||
{Ash.Resource, "mnesia", _} ->
|
||||
{[], Ash.DataLayer.Mnesia}
|
||||
|
||||
{Ash.Resource, "embedded", _} ->
|
||||
{[], &embedded_patcher(&1, module, path)}
|
||||
|
||||
{Ash.Resource, "json_api", _} ->
|
||||
{[:ash_json_api], AshJsonApi.Resource}
|
||||
|
||||
{Ash.Resource, "graphql", _} ->
|
||||
{[:ash_graphql], AshGraphql.Resource}
|
||||
|
||||
{Ash.Domain, "json_api", _} ->
|
||||
{[:ash_json_api], AshJsonApi.Domain}
|
||||
|
||||
{Ash.Domain, "graphql", _} ->
|
||||
{[:ash_graphql], AshGraphql.Domain}
|
||||
|
||||
{_kind_of_thing, _, extension} ->
|
||||
{[], extension}
|
||||
end
|
||||
|
||||
if is_function(extension) do
|
||||
{extension, install}
|
||||
else
|
||||
Module.concat([extension])
|
||||
|
||||
if Code.ensure_loaded?(extension) do
|
||||
fun =
|
||||
if function_exported?(extension, :install, 4) do
|
||||
fn igniter ->
|
||||
extension.install(igniter, module, kind_of_thing, path, argv)
|
||||
|> simple_add_extension(kind_of_thing, path, extension)
|
||||
end
|
||||
else
|
||||
&simple_add_extension(&1, kind_of_thing, path, extension)
|
||||
end
|
||||
|
||||
{fun, install}
|
||||
else
|
||||
extensions = Enum.map(Ash.Mix.Tasks.Helpers.extensions!([]), &inspect/1)
|
||||
|
||||
short_codes = [
|
||||
"json_api",
|
||||
"postgres",
|
||||
"graphql",
|
||||
"mysql",
|
||||
"sqlite",
|
||||
"ets",
|
||||
"mnesia",
|
||||
"embedded"
|
||||
]
|
||||
|
||||
installable =
|
||||
short_codes
|
||||
|> Enum.concat(extensions)
|
||||
|> Enum.map_join("\n", &" * #{&1}")
|
||||
|
||||
{:error,
|
||||
"""
|
||||
Could not find extension #{original_request}.
|
||||
|
||||
Possible values for extensions
|
||||
|
||||
#{installable}
|
||||
"""}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp embedded_patcher(igniter, resource, path) do
|
||||
domain =
|
||||
resource
|
||||
|> Module.split()
|
||||
|> :lists.droplast()
|
||||
|> Module.concat()
|
||||
|
||||
igniter
|
||||
|> remove_domain_option(path)
|
||||
|> Spark.Igniter.add_extension(path, Ash.Resource, :data_layer, :embedded, true)
|
||||
|> Ash.Domain.Igniter.remove_resource_reference(domain, resource)
|
||||
|> Spark.Igniter.update_dsl(
|
||||
Ash.Resource,
|
||||
path,
|
||||
[{:section, :actions}, {:option, :defaults}],
|
||||
[:read, :destroy, create: [], update: []],
|
||||
fn x -> x end
|
||||
)
|
||||
end
|
||||
|
||||
defp remove_domain_option(igniter, path) do
|
||||
Igniter.update_elixir_file(igniter, path, fn zipper ->
|
||||
with {:ok, zipper} <- Igniter.Code.Module.move_to_module_using(zipper, Ash.Resource),
|
||||
{:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ash.Resource),
|
||||
{:ok, zipper} <-
|
||||
Igniter.Code.Function.update_nth_argument(zipper, 1, fn values_zipper ->
|
||||
values_zipper
|
||||
|> Igniter.Code.Keyword.remove_keyword_key(:domain)
|
||||
end) do
|
||||
zipper
|
||||
else
|
||||
_ ->
|
||||
zipper
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp simple_add_extension(igniter, Ash.Resource, path, extension) do
|
||||
cond do
|
||||
Spark.implements_behaviour?(extension, Ash.DataLayer) ->
|
||||
Spark.Igniter.add_extension(igniter, path, Ash.Resource, :data_layer, extension, true)
|
||||
|
||||
Spark.implements_behaviour?(extension, Ash.Notifier) ->
|
||||
Spark.Igniter.add_extension(igniter, path, Ash.Resource, :notifiers, extension)
|
||||
|
||||
Spark.implements_behaviour?(extension, Ash.Authorizer) ->
|
||||
Spark.Igniter.add_extension(igniter, path, Ash.Resource, :authorizers, extension)
|
||||
|
||||
true ->
|
||||
igniter
|
||||
end
|
||||
end
|
||||
|
||||
defp simple_add_extension(igniter, type, path, extension) do
|
||||
Spark.Igniter.add_extension(igniter, path, type, :extensions, extension)
|
||||
end
|
||||
end
|
68
lib/mix/tasks/gen/ash.gen.base_resource.ex
Normal file
68
lib/mix/tasks/gen/ash.gen.base_resource.ex
Normal file
|
@ -0,0 +1,68 @@
|
|||
defmodule Mix.Tasks.Ash.Gen.BaseResource do
|
||||
@moduledoc """
|
||||
Generates a base resource
|
||||
|
||||
For example: `mix ash.gen.base_resource The.Resource.Name`
|
||||
"""
|
||||
@shortdoc "Generates a base resource. This is a module that you can use instead of `Ash.Resource`, for consistency."
|
||||
use Igniter.Mix.Task
|
||||
|
||||
@impl Igniter.Mix.Task
|
||||
def igniter(igniter, [base_resource | _argv]) do
|
||||
base_resource = Igniter.Code.Module.parse(base_resource)
|
||||
base_resource_file = Igniter.Code.Module.proper_location(base_resource)
|
||||
|
||||
glob = Path.join([base_resource_file, "..", "**", "*.ex"])
|
||||
|
||||
app_name = Igniter.Project.Application.app_name()
|
||||
|
||||
# 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.create_new_elixir_file(base_resource_file, """
|
||||
defmodule #{inspect(base_resource)} do
|
||||
defmacro __using__(opts) do
|
||||
quote do
|
||||
use Ash.Resource, unquote(opts)
|
||||
end
|
||||
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_glob(glob, 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
|
44
lib/mix/tasks/gen/ash.gen.domain.ex
Normal file
44
lib/mix/tasks/gen/ash.gen.domain.ex
Normal file
|
@ -0,0 +1,44 @@
|
|||
defmodule Mix.Tasks.Ash.Gen.Domain do
|
||||
@moduledoc """
|
||||
Generates an Ash.Domain
|
||||
|
||||
For example: `mix ash.gen.domain The.Domain`
|
||||
"""
|
||||
|
||||
@shortdoc "Generates an Ash.Domain"
|
||||
use Igniter.Mix.Task
|
||||
|
||||
@impl Igniter.Mix.Task
|
||||
def igniter(igniter, [domain | argv]) do
|
||||
domain = Igniter.Code.Module.parse(domain)
|
||||
domain_file = Igniter.Code.Module.proper_location(domain)
|
||||
|
||||
app_name = Igniter.Project.Application.app_name()
|
||||
|
||||
if "--ignore-if-exists" in argv && Igniter.exists?(igniter, domain_file) do
|
||||
igniter
|
||||
else
|
||||
igniter
|
||||
|> Igniter.create_new_elixir_file(domain_file, """
|
||||
defmodule #{inspect(domain)} do
|
||||
use Ash.Domain
|
||||
|
||||
resources do
|
||||
end
|
||||
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
|
53
lib/mix/tasks/gen/ash.gen.enum.ex
Normal file
53
lib/mix/tasks/gen/ash.gen.enum.ex
Normal file
|
@ -0,0 +1,53 @@
|
|||
defmodule Mix.Tasks.Ash.Gen.Enum do
|
||||
@moduledoc """
|
||||
Generates an Ash.Type.Enum
|
||||
|
||||
For example `mix ash.gen.enum The.Enum.Name list,of,values`
|
||||
|
||||
## 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 igniter(igniter, [module_name, types | argv]) do
|
||||
enum = Igniter.Code.Module.parse(module_name)
|
||||
file_name = Igniter.Code.Module.proper_location(enum)
|
||||
|
||||
{opts, _argv} =
|
||||
OptionParser.parse!(argv, switches: [short_name: :string], aliases: [s: :short_name])
|
||||
|
||||
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_elixir_file(file_name, """
|
||||
defmodule #{inspect(enum)} do
|
||||
use Ash.Type.Enum, values: #{inspect(types)}
|
||||
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
|
326
lib/mix/tasks/gen/ash.gen.resource.ex
Normal file
326
lib/mix/tasks/gen/ash.gen.resource.ex
Normal file
|
@ -0,0 +1,326 @@
|
|||
defmodule Mix.Tasks.Ash.Gen.Resource do
|
||||
@moduledoc """
|
||||
Generate and configures an Ash.Resource.
|
||||
|
||||
## What changes take place?
|
||||
|
||||
If the domain does not exist, we create it. If it does, we add the resource to it if it is not already present.
|
||||
|
||||
## Options
|
||||
|
||||
* `--attribute` or `-a` - An attribute or comma separated list of attributes to add, as `name:type`. Modifiers: `primary_key`, `public`, 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`, and `required`. i.e `-r belongs_to:author:MyApp.Accounts.Author:required`
|
||||
* `--default-actions` or `-da` - A csv list of default action types to add, i.e `-da read,create`. The `create` and `update` actions accept the public attributes being added.
|
||||
* `--uuid-primary-key` or `-u` - Adds a UUID primary key with that name. i.e `-u id`
|
||||
* `--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`
|
||||
"""
|
||||
|
||||
@shortdoc "Generate an Ash.Resource."
|
||||
use Igniter.Mix.Task
|
||||
|
||||
@impl Igniter.Mix.Task
|
||||
def igniter(igniter, [resource | argv]) do
|
||||
resource = Igniter.Code.Module.parse(resource)
|
||||
app_name = Igniter.Project.Application.app_name()
|
||||
|
||||
{options, _, _} =
|
||||
OptionParser.parse(argv,
|
||||
strict: [
|
||||
attribute: :keep,
|
||||
relationship: :keep,
|
||||
default_actions: :keep,
|
||||
uuid_primary_key: :string,
|
||||
integer_primary_key: :string,
|
||||
domain: :string,
|
||||
extend: :keep,
|
||||
base: :string
|
||||
],
|
||||
aliases: [
|
||||
a: :attribute,
|
||||
r: :relationship,
|
||||
da: :default_actions,
|
||||
d: :domain,
|
||||
u: :uuid_primary_key,
|
||||
i: :integer_primary_key,
|
||||
e: :extend,
|
||||
b: :base
|
||||
]
|
||||
)
|
||||
|
||||
domain =
|
||||
case options[:domain] do
|
||||
nil ->
|
||||
resource
|
||||
|> Module.split()
|
||||
|> :lists.droplast()
|
||||
|> Module.concat()
|
||||
|
||||
domain ->
|
||||
Igniter.Code.Module.parse(domain)
|
||||
end
|
||||
|
||||
options =
|
||||
options
|
||||
|> Ash.Igniter.csv_option(:default_actions, fn values ->
|
||||
Enum.sort_by(values, &(&1 in ["create", "update"]))
|
||||
end)
|
||||
|> Ash.Igniter.csv_option(:attribute)
|
||||
|> Ash.Igniter.csv_option(:relationship)
|
||||
|> Ash.Igniter.csv_option(:extend)
|
||||
|> Keyword.put_new(:base, "Ash.Resource")
|
||||
|
||||
base =
|
||||
if options[:base] == "Ash.Resource" do
|
||||
"Ash.Resource"
|
||||
else
|
||||
base =
|
||||
Igniter.Code.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.
|
||||
|
||||
If it exists but is not in the base resource list, add it like so:
|
||||
|
||||
`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
|
||||
|
||||
inspect(base)
|
||||
end
|
||||
|
||||
attributes = attributes(options)
|
||||
|
||||
relationships =
|
||||
if !Enum.empty?(options[:relationship]) do
|
||||
"""
|
||||
relationships do
|
||||
#{relationships(options)}
|
||||
end
|
||||
"""
|
||||
end
|
||||
|
||||
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
|
||||
[]
|
||||
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
|
||||
|
||||
attributes =
|
||||
if options[:uuid_primary_key] || options[:integer_primary_key] ||
|
||||
!Enum.empty?(options[:attribute]) do
|
||||
uuid_primary_key =
|
||||
if options[:uuid_primary_key] do
|
||||
pkey_builder("uuid_primary_key", options[:uuid_primary_key])
|
||||
end
|
||||
|
||||
integer_primary_key =
|
||||
if options[:integer_primary_key] do
|
||||
pkey_builder("integer_primary_key", options[:integer_primary_key])
|
||||
end
|
||||
|
||||
"""
|
||||
attributes do
|
||||
#{uuid_primary_key}
|
||||
#{integer_primary_key}
|
||||
#{attributes}
|
||||
end
|
||||
"""
|
||||
end
|
||||
|
||||
igniter
|
||||
|> Igniter.compose_task("ash.gen.domain", [inspect(domain), "--ignore-if-exists"])
|
||||
|> Ash.Domain.Igniter.add_resource_reference(
|
||||
domain,
|
||||
resource
|
||||
)
|
||||
|> Igniter.create_new_elixir_file(
|
||||
Igniter.Code.Module.proper_location(resource),
|
||||
"""
|
||||
defmodule #{inspect(resource)} do
|
||||
use #{base},
|
||||
otp_app: #{inspect(app_name)},
|
||||
domain: #{inspect(domain)}
|
||||
|
||||
#{actions}
|
||||
|
||||
#{attributes}
|
||||
|
||||
#{relationships}
|
||||
end
|
||||
"""
|
||||
)
|
||||
|> extend(resource, options[:extend], argv)
|
||||
end
|
||||
|
||||
defp extend(igniter, _, [], _) do
|
||||
igniter
|
||||
end
|
||||
|
||||
defp extend(igniter, resource, extensions, argv) do
|
||||
Igniter.compose_task(
|
||||
igniter,
|
||||
"ash.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
|
||||
"""
|
||||
#{builder} :#{name} do
|
||||
#{attribute_modifier_string(modifiers)}
|
||||
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)
|
||||
|
||||
"attribute :#{name}, #{inspect(type)}"
|
||||
|
||||
{name, type, modifiers} ->
|
||||
modifier_string = attribute_modifier_string(modifiers)
|
||||
|
||||
type = resolve_type(type)
|
||||
|
||||
"""
|
||||
attribute :#{name}, #{inspect(type)} do
|
||||
#{modifier_string}
|
||||
end
|
||||
"""
|
||||
end)
|
||||
end
|
||||
|
||||
defp attribute_modifier_string(modifiers) do
|
||||
Enum.map_join(modifiers, "\n", fn
|
||||
"primary_key" ->
|
||||
"primary_key? true"
|
||||
|
||||
"public" ->
|
||||
"public? true"
|
||||
|
||||
"required" ->
|
||||
"allow_nil? false"
|
||||
end)
|
||||
end
|
||||
|
||||
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 attribute format. 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"
|
||||
|
||||
"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)
|
||||
|
||||
"""
|
||||
#{type} :#{name}, #{destination} do
|
||||
#{modifier_string}
|
||||
end
|
||||
"""
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_type(value) do
|
||||
if String.contains?(value, ".") do
|
||||
Module.concat([value])
|
||||
else
|
||||
String.to_atom(value)
|
||||
end
|
||||
end
|
||||
end
|
136
lib/mix/tasks/install/ash.install.ex
Normal file
136
lib/mix/tasks/install/ash.install.ex
Normal file
|
@ -0,0 +1,136 @@
|
|||
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
|
||||
]
|
||||
|
||||
def igniter(igniter, argv) do
|
||||
igniter
|
||||
|> Igniter.Project.Deps.add_dependency(:picosat_elixir, "~> 0.2")
|
||||
|> Igniter.compose_task("spark.install", argv)
|
||||
|> Igniter.Project.Formatter.import_dep(:ash)
|
||||
|> Igniter.Project.Config.configure(
|
||||
"config.exs",
|
||||
:spark,
|
||||
[:formatter, :"Ash.Resource", :section_order],
|
||||
@resource_default_section_order
|
||||
)
|
||||
|> Igniter.Project.Config.configure(
|
||||
"config.exs",
|
||||
:spark,
|
||||
[:formatter, :"Ash.Domain", :section_order],
|
||||
@domain_default_section_order
|
||||
)
|
||||
|> then(fn igniter ->
|
||||
if "--example" in argv do
|
||||
generate_example(igniter, argv)
|
||||
else
|
||||
igniter
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp generate_example(igniter, argv) do
|
||||
domain_module_name = Igniter.Code.Module.module_name("Support")
|
||||
ticket_resource = Igniter.Code.Module.module_name("Support.Ticket")
|
||||
representative_resource = Igniter.Code.Module.module_name("Support.Representative")
|
||||
ticket_status_module_name = Igniter.Code.Module.module_name("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 []
|
||||
|
||||
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
|
||||
end
|
3
mix.exs
3
mix.exs
|
@ -356,6 +356,9 @@ defmodule Ash.MixProject do
|
|||
{:picosat_elixir, "~> 0.2", optional: true},
|
||||
{:simple_sat, "~> 0.1 and >= 0.1.1", optional: true},
|
||||
|
||||
# Code Generators
|
||||
{:igniter, "~> 0.1"},
|
||||
|
||||
# Dev/Test dependencies
|
||||
{:eflame, "~> 1.0", only: [:dev, :test]},
|
||||
{:ex_doc, "~> 0.32", only: [:dev, :test], runtime: false},
|
||||
|
|
Loading…
Reference in a new issue