Ash idea: Nested changeset of relationships

Hi,

I’ve been using Ash for a little while now to build a web app and think it’s a really cool and powerful tool and wanted to outline a way I’ve slightly extended it which might be useful to others / make its way into Ash itself(?!)

One cool thing with Ash is how it builds a changeset based on an action on a resource prior to performing the action but I’ve found it to be somewhat limited when it comes to nested actions

I’ll use the following (mostly working but kinda pseudo-code) example when discussing this idea, basically a way of adding an entry to a accounting ledger and have that reflect on the user’s account balance

defmodule MyApp.Entry do
  use Ash.Resource, otp_app: :my_app, data_layer: AshPostgres.DataLayer

  actions do
    create :create_and_update_account_balance do
      accept [:account_id, :amount]

      change fn changeset, _context ->
        Ash.Changeset.manage_relationship(
          changeset,
          :accounts,
          %{account_id: changeset.attributes.account_id, amount: changeset.attributes.amount},
          on_match: {:update, :update_balance}
        )
      end
    end
  end
end

defmodule MyApp.Account do
  use Ash.Resource, otp_app: :my_app, data_layer: AshPostgres.DataLayer

  actions do
    update :update_balance do
      argument :amount, :decimal do
        allow_nil? false
      end

      # This checks that the user's account balance will be greater than 0 when being debited
      # Both in memory and atomically in the db
      validate {MyApp.Validations.SufficientBalance, []},
        where: [compare(:amount, less_than: Decimal.new(0))]

      change atomic_update(:balance, expr(balance + ^arg(:amount)))
    end
  end
end

What happens at the moment is that only the validations on the called action are run, not those of managed relationships:

account = MyApp.Account |> limit(1) |> Ash.read_one!
account.balance
# Decimal.new(1)

changeset = MyApp.Entry |> Ash.Changeset.for_create(:create_and_update_account_balance, %{account_id: account.id, amount: Decimal.new(-2)})
# #Ash.Changeset<
#   …
#   errors: []
# }

changeset |> Ash.create!
# Invalid Error
# * balance: insufficient_balance

What I would hope to happen here is that it add an error in the changeset if the account balance will be less than 0

One place where this would be useful is giving the user feedback as to whether they can perform an action, without needing to try and then getting an error message
Importantly, by writing the code once

E.g. disabling a button:

# live.ex
changeset = MyApp.Entry |> Ash.Changeset.for_create(:create_and_update_account_balance, %{account_id: account.id, amount: Decimal.new(-2)})
# Ideal:
# #Ash.Changeset<
#   …
#   errors: [
#     %Ash.Error.Changes.InvalidChanges{
#       fields: [:balance],
#       message: :insufficient_balance,
#       validation: nil,
#       value: nil,
#       splode: nil,
#       bread_crumbs: [],
#       vars: [],
#       path: [],
#       stacktrace: #Splode.Stacktrace<>,
#       class: :invalid
#     }
#   ]
# }
{:noreply, socket |> assign(:changeset, changeset)}

# live.html.heex
<button disabled={@changeset.errors != []}>Buy now</button>

I’ve been able to hack in this functionality by creating a helper function (this should work for infinitely deep actions with similar functions for create & destroy):

defmodule MyApp.Changeset do
  def update_relationship(changeset, module, relationship, input, opts) do
    {:update, action_name} = Keyword.fetch!(opts, :on_match)

    %{errors: errors} =
      module
      |> Ash.Changeset.for_update(action, input)

    changeset
    |> Ash.Changeset.add_error(errors)
    |> Ash.Changeset.manage_relationship(
      relationship,
      input,
      opts
    )
  end
end

defmodule MyApp.Entry do
  use Ash.Resource, otp_app: :my_app, data_layer: AshPostgres.DataLayer

  actions do
    create :create_and_update_account_balance do
      accept [:account_id, :amount]

      change fn changeset, _context ->
        MyApp.Changeset.update_relationship(
          changeset,
          MyApp.Account,
          :accounts,
          %{account_id: changeset.attributes.account_id, amount: changeset.attributes.amount},
          on_match: {:update, :update_balance}
        )
      end
    end
  end
end

While this works for my current use case, it leaves much to be desired and I wonder if anyone has any thoughts on how this might be refined and potentially added to Ash or a utility package

My initial thoughts are:

  • Tap into the Ash’s introspection to clean up this code
  • Rather than returning a flat list of errors, have a way to make it nested which might make it better to match the error with the input in the case of nested forms (possibly using a new error type?)
  • Perhaps rather listing just the errors, the changeset could include all nested changesets so every change can be seen
  • This only works if you know what action to perform, Ash.Changeset.manage_relationship is generic and probably shouldn’t have this functionality itself

Thanks and happy coding!