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:
Zach Daniel 2024-06-21 19:09:35 -04:00
parent 229887347e
commit 5727cc273f
21 changed files with 1093 additions and 22 deletions

View file

@ -1,2 +1,2 @@
erlang 26.0.2
elixir 1.16.2
elixir 1.17.0

View file

@ -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

View file

@ -182,9 +182,6 @@ defmodule Ash.Actions.Read.Calculations do
else
{:calc, nil} ->
{:error, "No such calculation"}
{:error, error} ->
{:error, error}
end
end

View file

@ -1220,7 +1220,7 @@ defmodule Ash.Actions.Read do
calculation.type,
list,
load_statement,
calculation.constraints,
calculation.constraints[:items] || [],
%{
domain: domain,
actor: actor,

View file

@ -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

View file

@ -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
View 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

View file

@ -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
View 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
View 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

View file

@ -393,7 +393,7 @@ defmodule Ash.Policy.Authorizer do
require Logger
@behaviour Ash.Authorizer
use Ash.Authorizer
@transformers [
Ash.Policy.Authorizer.Transformers.AddMissingFieldPolicies,

View file

@ -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,

View 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

View file

@ -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
View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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},