Patterns for attribute-based state changes

When you’re building some kind of a CRUD type API, particularly when being quasi-RESTful, state changes typically happen with updating something like a status or state field. This ends up evoking other changes and/or defines what related changes can be made.

For instance, if I was to update to canceled I might have a field like cancellation_reason that would be required only on that state change. When handling things with dedicated functions or even actions, you have stuff like: “process” or “cancel” or whatever. This is place where something functional/RPC > REST/CRUD in my opinion.

How do I play this out in an Ash action(s)? Is there a way to have a generic update action that can then traffic control to the appropriate related action based on the updated value in status or do I end up needing to cram all of that logic into a update function?

With plain old functions I typically check if/what the change to the status field is then just route to different change sets accordingly. I might have something like Resource.cancellation_changeset/2 that checks:

  1. Can I even transition to cancelled from the current state?
  2. Are cancellation specific attributes included and correct?
  3. Are there any tools in AshStateMachine that would really help here? It appears that plugin is mainly just about making sure transitions follow a predictable flow.

There are definitely ways to “route” to other actions within an update action, typically using manual or generic actions. I typically suggest starting by putting the logic in changes, however. For example:

defmodule YourResource.Changes.TryCancel do
  use Ash.Resource.Change
    
  def change(changeset, _, _) do
    # your logic in a change can be "good old fashioned functions" that you compose as normal
    case try_cancel(changeset) do
      {:ok, changeset} -> ...something
      :error -> ...something else
    end
  end
end

update :cancel do
  change __MODULE__.Changes.TryCancel
end

Changes can be written in an abstract way to allow each action to be a relatively simple declaration of: 1. its interface and 2: its constituent parts

update :cancel do
  change {__MODULE__.Changes.TransitionState, to: :cancelled}
end

update :process do
  change {__MODULE__.Changes.TransitionState, to: :cancelled}
end

This is often far simpler than having router actions IMO. The TransitionState change proposed there would be able to “route” depending on conditions of the action/data, etc.

1 Like

The main reason for wanting to route through other actions is to take advantage of the DSLs and such.

For instance, let’s say I have a validation I want to run—as in something doing use Ash.Resource.Validation. How do I apply that to changeset inside the change?

(Looking at the actual data I can see an :atomic_validations key that’s just a list. Easy enough. But I don’t see any obvious official way to manipulate this outside of the validate DSL in an action.

Most of my interest in routing through actions is just this. How do I use building blocks like existing changes, validations, etc. outside of an action? And, if I’m not really meant to, it seems as if the answer is routing through other actions.

(I’ll have a look at generics in a moment.)

Right, I feel you. We’ve talked about writing composers for validation modules and change modules. I think maybe some utilities could be all it takes to make this work well?

like

def change(changeset, _, _) do
  if some_condition do
    Ash.Changeset.apply_validation(changeset, {Validation, opts})
  else
    changeset
  end
end

def atomic(changeset, _, _) do
  if some_condition do
    Ash.Changeset.apply_atomic_validation(changeset, {Validation, opts})
  else
    changeset
  end
end

That would actually work just fine.

Want me to take a stab at it? Haha.

It wouldn’t hurt at all to have these functions :smiley: Whether or not this is the route we go for general usage remains to be seen though. We’ve got some ideas.