Generic Actions

View Source

Generic actions are so named because there are no special rules about how they work. A generic action takes arguments and returns a value. The struct used for building input for a generic action is Ash.ActionInput.

action :say_hello, :string do
  argument :name, :string, allow_nil?: false

  run fn input, _ ->
    {:ok, "Hello: #{input.arguments.name}"}
  end
end

A generic action declares its arguments, return type, and implementation, as illustrated above.

No return? No problem!

Generic actions can omit a return type, in which case running them returns :ok if successful.

action :schedule_job do
  argument :job_name, :string, allow_nil?: false
  run fn input, _ ->
    # Schedule the job
    :ok
  end
end

For a full list of all of the available options for configuring generic actions, see the Ash.Resource.Dsl documentation.

Calling Generic Actions

The basic formula for calling a generic action looks like this:

Resource
|> Ash.ActionInput.for_action(:action_name, %{argument: :value}, ...opts)
|> Ash.run_action!()

See the code interface guide guide for how to define idiomatic and convenient functions that call your actions.

Why use generic actions?

The example above could be written as a normal function in elixir, i.e

def say_hello(name), do: "Hello: #{name}"

The benefit of using generic actions instead of defining normal functions:

  • They can be used with api extensions like ash_json_api and ash_graphql
  • Their inputs are type checked and casted
  • They support Ash authorization patterns (i.e policies)
  • They can be included in the code interface of a resource
  • They can be made transactional with a single option (transaction? true)

If you don't need any of the above, then there is no problem with writing regular Elixir functions!

Return types and constraints

Generic actions do not cast their return types. It is expected that the action return a valid value for the type that they declare. However, declaring additional constraints can inform API usage, and make the action more clear. For example:

action :priority, :integer do
  constraints [min: 1, max: 3]
  argument :status, :atom, constraints: [one_of: [:high, :medium, :low]]

  run fn input, _ ->
    case input.arguments.status do
      :high -> {:ok, 3}
      :medium -> {:ok, 2}
      :low -> {:ok, 1}
    end
  end
end

Returning resource instances

It sometimes happens that you want to make a generic action which returns an instance or instances of the resource. It's natural to assume that you can set your action's return type to the name of your resource. This won't work as resources do not define a type, unless they are embedded. In embedded resources, this won't work because the module is still being compiled, so referencing yourself as a type causes a compile error. Instead, use the :struct type and the instance_of constraint, like so:

action :get, :struct do
  constraints instance_of: __MODULE__

  run # ...
end

For returning many instances of the resource, you can set your action's return type to {:array, :struct} and set the items constraint to the name of your resource.

 action :list_resources, {:array, :struct} do
   constraints items: [instance_of: __MODULE__]

   run # ...
 end

Calling Generic Actions

To execute a generic action in Ash, follow these steps:

  1. Prepare the action input: Use Ash.ActionInput.for_action/4 to specify the resource, the action and its arguments.
  2. Run the action: Use Ash.run_action/2 to execute the action with the prepared input.

Example Usage

Consider an Ash.Resource with the action :say_hello:

action :say_hello, :string do
  argument :name, :string, allow_nil?: false

  run fn input, _ ->
    {:ok, "Hello: #{input.arguments.name}"}
  end
end

Call this action:

{:ok, greeting} = Resource
|> Ash.ActionInput.for_action(:say_hello, %{name: "Alice"})
|> Ash.run_action()

IO.puts(greeting)  # Output: Hello: Alice

Using Code Interface

You can also use Code Interfaces to call actions:

Given a definition like:

define :say_hello, args: [:name]
{:ok, greeting} = Resource.say_hello("Alice")
greeting = Resource.say_hello!("Alice")

Validations and Preparations

Generic actions support validations and preparations, allowing you to add business logic and input validation to your actions.

Validations

Validations in generic actions work similarly to those in other action types. They validate the action input before the action logic runs.

action :create_user, :struct do
  constraints instance_of: __MODULE__

  argument :name, :string, allow_nil?: false
  argument :email, :string, allow_nil?: false
  argument :age, :integer

  validate present([:name, :email])
  validate match(:email, ~r/@/)
  validate compare(:age, greater_than: 13) do
    message "Must be at least 13 years old"
  end

  run fn input, _ ->
    # Create user logic here
    {:ok, %__MODULE__{
      name: input.arguments.name,
      email: input.arguments.email,
      age: input.arguments.age
    }}
  end
end

You can also use custom validation modules:

action :transfer_funds, :boolean do
  argument :from_account, :string, allow_nil?: false
  argument :to_account, :string, allow_nil?: false
  argument :amount, :decimal, allow_nil?: false

  validate {MyApp.Validations.SufficientFunds, field: :amount}

  run fn input, _ ->
    # Transfer logic here
    {:ok, true}
  end
end

Preparations

Preparations allow you to modify the action input before the action runs. This is useful for setting computed values or applying business logic.

action :audit_log, :string do
  argument :action, :string, allow_nil?: false
  argument :details, :map, default: %{}

  prepare fn input, _context ->
    # Add timestamp and actor information
    updated_details = Map.merge(input.arguments.details, %{
      timestamp: DateTime.utc_now(),
      actor_id: input.context[:actor]&.id
    })

    Ash.ActionInput.set_argument(input, :details, updated_details)
  end

  run fn input, _ ->
    # Log the action
    log_entry = "#{input.arguments.action}: #{inspect(input.arguments.details)}"
    {:ok, log_entry}
  end
end

You can also use the built-in build preparation:

action :search_with_defaults do
  argument :query, :string
  argument :filters, :map, default: %{}

  prepare build(
    arguments: %{
      filters: expr(Map.merge(^arg(:filters), %{active: true}))
    }
  )

  run fn input, _ ->
    # Search logic with default filters applied
    {:ok, perform_search(input.arguments.query, input.arguments.filters)}
  end
end

Action Hooks

Generic actions support action-level hooks that run before and after the action execution.

Before Action Hooks

Before action hooks run immediately before the action logic executes:

action :process_payment, :boolean do
  argument :amount, :decimal, allow_nil?: false
  argument :payment_method, :string, allow_nil?: false

  validate present([:amount, :payment_method])

  # Using a function
  prepare before_action(fn input, _context ->
    # Log the payment attempt
    Logger.info("Processing payment of #{input.arguments.amount}")

    # Validate payment method
    if input.arguments.payment_method not in ["credit_card", "bank_transfer"] do
      Ash.ActionInput.add_error(input, "Invalid payment method")
    else
      input
    end
  end)

  run fn input, _ ->
    # Process payment logic
    {:ok, true}
  end
end

After Action Hooks

After action hooks run after successful action execution:

action :send_notification, :boolean do
  argument :message, :string, allow_nil?: false
  argument :recipient, :string, allow_nil?: false

  prepare after_action(fn input, result, _context ->
    # Log successful notification
    Logger.info("Notification sent to #{input.arguments.recipient}")

    # Could perform additional side effects here
    {:ok, result}
  end)

  run fn input, _ ->
    # Send notification logic
    send_notification(input.arguments.recipient, input.arguments.message)
    {:ok, true}
  end
end

Using Custom Preparation Modules

You can also create reusable preparation modules for generic actions:

defmodule MyApp.Preparations.AuditAction do
  use Ash.Resource.Preparation

  def prepare(input, _opts, context) do
    Ash.ActionInput.before_action(input, fn input ->
      # Log the action attempt
      MyApp.AuditLog.log_action(input.action.name, input.arguments, context.actor)
      input
    end)
    |> Ash.ActionInput.after_action(fn input, result ->
      # Log successful completion
      MyApp.AuditLog.log_success(input.action.name, result, context.actor)
      {:ok, result}
    end)
  end
end

Then use it in your action:

action :sensitive_operation, :boolean do
  argument :data, :map, allow_nil?: false

  prepare MyApp.Preparations.AuditAction

  run fn input, _ ->
    # Sensitive operation logic
    {:ok, true}
  end
end

Global Validations and Preparations

Generic actions also support global validations and preparations defined at the resource level:

defmodule MyApp.MyResource do
  use Ash.Resource

  # Global preparations that apply to all actions
  preparations do
    prepare fn input, _context ->
      # Add tenant information to all actions
      Ash.ActionInput.set_context(input, %{tenant: "default"})
    end do
      # Only apply to generic actions
      on: [:action]
    end
  end

  # Global validations that apply to all actions
  validations do
    validate present(:actor) do
      message "Authentication required"
      on: [:action]  # Only apply to generic actions
    end
  end

  actions do
    action :my_action do
      # Action-specific logic
    end
  end
end

Execution Order

For generic actions, the execution order is:

  1. Global preparations/validations (in order of definition)
  2. Action preparations/validations (in order of definition)
  3. before_action hooks
  4. Action logic execution
  5. after_action hooks (success only)

This order ensures that global business logic runs first, followed by action-specific logic, and finally the action hooks.