setup usage_rules
This commit is contained in:
401
.claude/skills/ash-framework/references/actions.md
Normal file
401
.claude/skills/ash-framework/references/actions.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# Actions
|
||||
|
||||
- Create specific, well-named actions rather than generic ones
|
||||
- Put all business logic inside action definitions
|
||||
- Use hooks like `Ash.Changeset.after_action/2`, `Ash.Changeset.before_action/2` to add additional logic
|
||||
inside the same transaction.
|
||||
- Use hooks like `Ash.Changeset.after_transaction/2`, `Ash.Changeset.before_transaction/2` to add additional logic
|
||||
outside the transaction.
|
||||
- Use action arguments for inputs that need validation
|
||||
- Use preparations to modify queries before execution
|
||||
- Preparations support `where` clauses for conditional execution
|
||||
- Use `only_when_valid?` to skip preparations when the query is invalid
|
||||
- Use changes to modify changesets before execution
|
||||
- Use validations to validate changesets before execution
|
||||
- Prefer domain code interfaces to call actions instead of directly building queries/changesets and calling functions in the `Ash` module
|
||||
- A resource could be *only generic actions*. This can be useful when you are using a resource only to model behavior.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Functions to call actions, like `Ash.create` and code interfaces like `MyApp.Accounts.register_user` all return ok/error tuples. All have `!` variations, like `Ash.create!` and `MyApp.Accounts.register_user!`. Use the `!` variations when you want to "let it crash", like if looking something up that should definitely exist, or calling an action that should always succeed. Always prefer the raising `!` variation over something like `{:ok, user} = MyApp.Accounts.register_user(...)`.
|
||||
|
||||
All Ash code returns errors in the form of `{:error, error_class}`. Ash categorizes errors into four main classes:
|
||||
|
||||
1. **Forbidden** (`Ash.Error.Forbidden`) - Occurs when a user attempts an action they don't have permission to perform
|
||||
2. **Invalid** (`Ash.Error.Invalid`) - Occurs when input data doesn't meet validation requirements
|
||||
3. **Framework** (`Ash.Error.Framework`) - Occurs when there's an issue with how Ash is being used
|
||||
4. **Unknown** (`Ash.Error.Unknown`) - Occurs for unexpected errors that don't fit the other categories
|
||||
|
||||
These error classes help you catch and handle errors at an appropriate level of granularity. An error class will always be the "worst" (highest in the above list) error class from above. Each error class can contain multiple underlying errors, accessible via the `errors` field on the exception.
|
||||
|
||||
## Using Validations
|
||||
|
||||
Validations ensure that data meets your business requirements before it gets processed by an action. Unlike changes, validations cannot modify the changeset - they can only validate it or add errors.
|
||||
|
||||
Validations work on both changesets and queries. Built-in validations that support queries include:
|
||||
- `action_is`, `argument_does_not_equal`, `argument_equals`, `argument_in`
|
||||
- `compare`, `confirm`, `match`, `negate`, `one_of`, `present`, `string_length`
|
||||
- Custom validations that implement the `supports/1` callback
|
||||
|
||||
Common validation patterns:
|
||||
|
||||
```elixir
|
||||
# Built-in validations with custom messages
|
||||
validate compare(:age, greater_than_or_equal_to: 18) do
|
||||
message "You must be at least 18 years old"
|
||||
end
|
||||
validate match(:email, "@")
|
||||
validate one_of(:status, [:active, :inactive, :pending])
|
||||
|
||||
# Conditional validations with where clauses
|
||||
validate present(:phone_number) do
|
||||
where present(:contact_method) and eq(:contact_method, "phone")
|
||||
end
|
||||
|
||||
# only_when_valid? - skip validation if prior validations failed
|
||||
validate expensive_validation() do
|
||||
only_when_valid? true
|
||||
end
|
||||
|
||||
# Action-specific vs global validations
|
||||
actions do
|
||||
create :sign_up do
|
||||
validate present([:email, :password]) # Only for this action
|
||||
end
|
||||
|
||||
read :search do
|
||||
argument :email, :string
|
||||
validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) # Validates query arguments
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present([:title, :body]), on: [:create, :update] # Multiple actions
|
||||
end
|
||||
```
|
||||
|
||||
- Create **custom validation modules** for complex validation logic:
|
||||
```elixir
|
||||
defmodule MyApp.Validations.UniqueUsername do
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
# Validation logic here
|
||||
# Return :ok or {:error, message}
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in resource:
|
||||
validate {MyApp.Validations.UniqueUsername, []}
|
||||
```
|
||||
|
||||
- Make validations **atomic** when possible to ensure they work correctly with direct database operations by implementing the `atomic/3` callback in custom validation modules.
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Validations.IsEven do
|
||||
# transform and validate opts
|
||||
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
if is_atom(opts[:attribute]) do
|
||||
{:ok, opts}
|
||||
else
|
||||
{:error, "attribute must be an atom!"}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
# This is optional, but useful to have in addition to validation
|
||||
# so you get early feedback for validations that can otherwise
|
||||
# only run in the datalayer
|
||||
def validate(changeset, opts, _context) do
|
||||
value = Ash.Changeset.get_attribute(changeset, opts[:attribute])
|
||||
|
||||
if is_nil(value) || (is_number(value) && rem(value, 2) == 0) do
|
||||
:ok
|
||||
else
|
||||
{:error, field: opts[:attribute], message: "must be an even number"}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic(changeset, opts, context) do
|
||||
{:atomic,
|
||||
# the list of attributes that are involved in the validation
|
||||
[opts[:attribute]],
|
||||
# the condition that should cause the error
|
||||
# here we refer to the new value or the current value
|
||||
expr(rem(^atomic_ref(opts[:attribute]), 2) != 0),
|
||||
# the error expression
|
||||
expr(
|
||||
error(^InvalidAttribute, %{
|
||||
field: ^opts[:attribute],
|
||||
# the value that caused the error
|
||||
value: ^atomic_ref(opts[:attribute]),
|
||||
# the message to display
|
||||
message: ^(context.message || "%{field} must be an even number"),
|
||||
vars: %{field: ^opts[:attribute]}
|
||||
})
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- **Avoid redundant validations** - Don't add validations that duplicate attribute constraints:
|
||||
```elixir
|
||||
# WRONG - redundant validation
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
constraints min_length: 1
|
||||
end
|
||||
|
||||
validate present(:name) do # Redundant! allow_nil? false already handles this
|
||||
message "Name is required"
|
||||
end
|
||||
|
||||
validate attribute_does_not_equal(:name, "") do # Redundant! min_length: 1 already handles this
|
||||
message "Name cannot be empty"
|
||||
end
|
||||
|
||||
# CORRECT - let attribute constraints handle basic validation
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
constraints min_length: 1
|
||||
end
|
||||
```
|
||||
|
||||
## Using Preparations
|
||||
|
||||
Preparations modify queries before they're executed. They are used to add filters, sorts, or other query modifications based on the query context.
|
||||
|
||||
Common preparation patterns:
|
||||
|
||||
```elixir
|
||||
# Built-in preparations
|
||||
prepare build(sort: [created_at: :desc])
|
||||
prepare build(filter: [active: true])
|
||||
|
||||
# Conditional preparations with where clauses
|
||||
prepare build(filter: [visible: true]) do
|
||||
where argument_equals(:include_hidden, false)
|
||||
end
|
||||
|
||||
# only_when_valid? - skip preparation if prior validations failed
|
||||
prepare expensive_preparation() do
|
||||
only_when_valid? true
|
||||
end
|
||||
|
||||
# Action-specific vs global preparations
|
||||
actions do
|
||||
read :recent do
|
||||
prepare build(sort: [created_at: :desc], limit: 10)
|
||||
end
|
||||
end
|
||||
|
||||
preparations do
|
||||
prepare build(filter: [deleted: false]), on: [:read, :update]
|
||||
end
|
||||
```
|
||||
|
||||
## Using Changes
|
||||
|
||||
Changes allow you to modify the changeset before it gets processed by an action. Unlike validations, changes can manipulate attribute values, add attributes, or perform other data transformations.
|
||||
|
||||
Common change patterns:
|
||||
|
||||
```elixir
|
||||
# Built-in changes with conditions
|
||||
change set_attribute(:status, "pending")
|
||||
change relate_actor(:creator) do
|
||||
where present(:actor)
|
||||
end
|
||||
change atomic_update(:counter, expr(^counter + 1))
|
||||
|
||||
# Action-specific vs global changes
|
||||
actions do
|
||||
create :sign_up do
|
||||
change set_attribute(:joined_at, expr(now())) # Only for this action
|
||||
end
|
||||
end
|
||||
|
||||
changes do
|
||||
change set_attribute(:updated_at, expr(now())), on: :update # Multiple actions
|
||||
change manage_relationship(:items, type: :append), on: [:create, :update]
|
||||
end
|
||||
```
|
||||
|
||||
- Create **custom change modules** for reusable transformation logic:
|
||||
```elixir
|
||||
defmodule MyApp.Changes.SlugifyTitle do
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
title = Ash.Changeset.get_attribute(changeset, :title)
|
||||
|
||||
if title do
|
||||
slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-")
|
||||
Ash.Changeset.change_attribute(changeset, :slug, slug)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in resource:
|
||||
change {MyApp.Changes.SlugifyTitle, []}
|
||||
```
|
||||
|
||||
- Create a **change module with lifecycle hooks** to handle complex multi-step operations:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Changes.ProcessOrder do
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, context) do
|
||||
changeset
|
||||
|> Ash.Changeset.before_transaction(fn changeset ->
|
||||
# Runs before the transaction starts
|
||||
# Use for external API calls, logging, etc.
|
||||
MyApp.ExternalService.reserve_inventory(changeset, scope: context)
|
||||
changeset
|
||||
end)
|
||||
|> Ash.Changeset.before_action(fn changeset ->
|
||||
# Runs inside the transaction before the main action
|
||||
# Use for related database changes in the same transaction
|
||||
Ash.Changeset.change_attribute(changeset, :processed_at, DateTime.utc_now())
|
||||
end)
|
||||
|> Ash.Changeset.after_action(fn changeset, result ->
|
||||
# Runs inside the transaction after the main action, only on success
|
||||
# Use for related database changes that depend on the result
|
||||
MyApp.Inventory.update_stock_levels(result, scope: context)
|
||||
{changeset, result}
|
||||
end)
|
||||
|> Ash.Changeset.after_transaction(fn changeset,
|
||||
{:ok, result} ->
|
||||
# Runs after the transaction completes (success or failure)
|
||||
# Use for notifications, external systems, etc.
|
||||
MyApp.Mailer.send_order_confirmation(result, scope: context)
|
||||
{changeset, result}
|
||||
|
||||
{:error, error} ->
|
||||
# Runs after the transaction completes (success or failure)
|
||||
# Use for notifications, external systems, etc.
|
||||
MyApp.Mailer.send_order_issue_notice(result, scope: context)
|
||||
{:error, error}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in resource:
|
||||
change {MyApp.Changes.ProcessOrder, []}
|
||||
```
|
||||
|
||||
## Atomic Changes
|
||||
|
||||
Atomic changes execute directly in the database as part of the update query, without requiring the record to be loaded first. This provides better performance and correct behavior under concurrent updates.
|
||||
|
||||
**Why atomic matters:**
|
||||
- Avoids race conditions (e.g., incrementing a counter)
|
||||
- Better performance (no round-trip to load the record)
|
||||
- Required for bulk operations to work efficiently
|
||||
|
||||
**Built-in atomic changes:**
|
||||
```elixir
|
||||
# Increment a counter atomically
|
||||
change atomic_update(:view_count, expr(view_count + 1))
|
||||
|
||||
# Set a value using an expression
|
||||
change set_attribute(:updated_at, expr(now()))
|
||||
```
|
||||
|
||||
**Making custom changes atomic:**
|
||||
Implement the `atomic/3` callback to support atomic execution:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Changes.IncrementVersion do
|
||||
use Ash.Resource.Change
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
# Fallback for non-atomic execution
|
||||
current = Ash.Changeset.get_attribute(changeset, :version) || 0
|
||||
Ash.Changeset.change_attribute(changeset, :version, current + 1)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic(_changeset, _opts, _context) do
|
||||
# Atomic implementation - runs in the database
|
||||
{:atomic, %{version: expr(coalesce(version, 0) + 1)}}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Using `require_atomic? false`
|
||||
|
||||
By default, update and destroy actions require all changes and validations to support atomic execution. If they don't, the action will raise an error.
|
||||
|
||||
**IMPORTANT:** When you see `require_atomic? false` on an action, carefully consider whether it is truly necessary. This option should be used sparingly.
|
||||
|
||||
**When `require_atomic? false` is needed:**
|
||||
- The action has `before_action` or `around_action` hooks that need to read or modify the record
|
||||
- A change reads the current record state (e.g., `Ash.Changeset.get_data/2`) and cannot be rewritten atomically
|
||||
- Complex validations that cannot be expressed as database expressions
|
||||
|
||||
**When `require_atomic? false` is NOT needed:**
|
||||
- Simple attribute transformations (these can usually be made atomic)
|
||||
- Setting timestamps or default values (use `expr(now())` instead)
|
||||
- Incrementing counters (use `atomic_update/2`)
|
||||
- After-action hooks (these don't prevent atomic execution)
|
||||
- After-transaction hooks (these don't prevent atomic execution)
|
||||
|
||||
```elixir
|
||||
actions do
|
||||
update :update do
|
||||
# AVOID unless truly necessary
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :increment_views do
|
||||
# GOOD - fully atomic, no need to disable
|
||||
change atomic_update(:view_count, expr(view_count + 1))
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
If you find yourself adding `require_atomic? false`, first check if your changes and validations can be rewritten with `atomic/3` callbacks. Only disable atomic requirements when the action genuinely needs to read or manipulate the record in hooks.
|
||||
|
||||
## Custom Modules vs. Anonymous Functions
|
||||
|
||||
Prefer to put code in its own module and refer to that in changes, preparations, validations etc.
|
||||
|
||||
For example, prefer this:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.MyDomain.MyResource.Changes.SlugifyName do
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _, _) do
|
||||
Ash.Changeset.before_action(changeset, fn changeset, _ ->
|
||||
slug = MyApp.Slug.get()
|
||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
change MyApp.MyDomain.MyResource.Changes.SlugifyName
|
||||
```
|
||||
|
||||
## Action Types
|
||||
|
||||
- **Read**: For retrieving records
|
||||
- **Create**: For creating records
|
||||
- **Update**: For changing records
|
||||
- **Destroy**: For removing records
|
||||
- **Generic**: For custom operations that don't fit the other types
|
||||
Reference in New Issue
Block a user