Conditions on atomic changes

I"ve been working on translating a few queries over to Ash and hitting a wall in regards to query expressions.

I have a resource that needs to perform some atomic updates given some conditions. This is how it looks like with ecto:

from(r in Resource,
        where: r.id == ^some_id,
        where: r.max_size >= ^attrs.size,
        where: r.count + ^attrs.size <= r.some_max or r.is_unlimited,
        update: [
          inc: [count: ^attrs.size]
        ],
        select: r
      )

I started by using simple changes + atomics for this, but not sure how to tie in the where clauses to the atomic updates.

So, we’re only just now getting this feature set in Ash, it’s fresh and still needs some improvements. However, our bulk update syntax ought to work for this.

Resource
|> Ash.Query.filter(.., %{size: size})
|> YourApi.bulk_update(:increment_size, %{size: size})

Then in your update action:

update :increment_size do
  accept []
  argument :size, :integer, allow_nil?: false
  change atomic_update(:count, expr(count + ^arg(:size)))
end

Probably more idiomatic would be explicitly stating the read action.

Resource
|> Ash.Query.for_read(:some_read_action, %{size: size})
|> YourApi.bulk_update(:increment_size, %{size: size})

@zachdaniel That looks awesome!

Do I have to wrap the increment_size in another action for the bulk updates?

Ideally I would want something like Resource.some_domain_action() to do both the increment and bulk update.

@zachdaniel Just to add context, this is for updating 1 resource, its not really a bulk update, it was just done this way because of how atomic updates work on Ecto. What is happening here is that I have a resource that serves as an aggregate, and some fields need to be atomic on it to update its children resources.

Ah, okay, then you can simplify that into a single update, and use if in the atomic update.

change atomic_update(:field, if .... do
  value + something
else
  value
end)

If you wanted to actually wrap a query + bulk update in an action, you’d create a generic action for that. AFAIK though we don’t currently have support for generic actions in AshJsonApi, but its more than possible.

1 Like

What can go in the if clause? Can I check against all fields in the resource? Is value the current value of the atomic field?

Here is what a conditional increment would look like:

change atomic_update(:field, if some_other_field > ^arg(:some_argument) do
  field + ^arg(:amount)
else
  field
end)

field refers to the current value of that field :slight_smile:

@zachdaniel Doesnt seem to compile when I reference any field(undefined variable)

right, sorry,

change atomic_update(:field, expr(if some_other_field > ^arg(:some_argument) do
  field + ^arg(:amount)
else
  field
end))

Nice, this works!

Is it possible to add an error when the expr conditions dont match? like adding an error in the else clause? Right now if there is no else, it nullifies the column

Hmm…it shouldn’t nullify it, it should set it to the previous value right?

Its currently setting the field to null, this is what I have:

change(fn changeset, _ ->
        changeset =
          Ash.Changeset.atomic_update(
            changeset,
            :count,
            expr(
              if(count + ^arg(:max_size) <= some_value or is_unlimited) do
                count + ^arg(:max_size)
              end
            )
          )
      end)

I think what I want to happen is that if the preconditions of the atomic update are not met, I get some sort of error, ideally something I can set to callers can take action on the failed update

Keeping in mind that this is a brand new feature set, you can actually use the new error expression.

change atomic_update(changeset, :count, expr(
  if is_unlimited or count + ^arg(:max_size) <= some_value do
    count + ^arg(:max_size)
  else
     error(
         Ash.Error.Changes.InvalidArgument,
         %{
           field: :max_size,
           value: ^arg(:max_size),
           message: "some message using a %{replacement}",
           vars: %{replacement: "foo"}
         }
  end
))
1 Like

That worked! Really nice. I did have to create a new migration for the “ash-functions” for it to work

hi @zachdaniel Not sure what changed with the recent version of Ash, but instead of returning an error tuple, actions with atomic updates using the error macro raise a {:error, :norollback, _someerror} error instead. Is this the new behaviour?

Here is an example stack trace:

 ** (Ash.Error.Unknown) Unknown Error

     * ** (CaseClauseError) no case clause matching: {:error, :no_rollback, %Ash.Error.Changes.InvalidAttribute{field: :my_count, message: "error message", private_vars: nil, value: "my_count", changeset: nil, query: nil, error_context: [], vars: [], path: [], stacktrace: #Stacktrace<>, class: :invalid}}
       (ash 2.20.1) lib/ash/actions/update/update.ex:432: anonymous fn/5 in Ash.Actions.Update.commit/3
       (ash 2.20.1) lib/ash/changeset/changeset.ex:2892: Ash.Changeset.run_around_actions/2
       (ash 2.20.1) lib/ash/changeset/changeset.ex:2475: anonymous fn/3 in Ash.Changeset.with_hooks/3
       (ecto_sql 3.11.1) lib/ecto/adapters/sql.ex:1358: anonymous fn/3 in Ecto.Adapters.SQL.checkout_or_transaction/4
       (db_connection 2.6.0) lib/db_connection.ex:1710: DBConnection.run_transaction/4
       (ash 2.20.1) lib/ash/changeset/changeset.ex:2473: anonymous fn/3 in Ash.Changeset.with_hooks/3
       (ash 2.20.1) lib/ash/changeset/changeset.ex:2612: anonymous fn/2 in Ash.Changeset.transaction_hooks/2
       (ash 2.20.1) lib/ash/changeset/changeset.ex:2454: Ash.Changeset.with_hooks/3
       (ash 2.20.1) lib/ash/actions/update/update.ex:354: Ash.Actions.Update.commit/3
       (ash 2.20.1) lib/ash/actions/update/update.ex:229: Ash.Actions.Update.do_run/4
       (ash 2.20.1) lib/ash/actions/update/update.ex:188: Ash.Actions.Update.run/4

Can you try main of ash? Just pushed up a fix.

Just tested this out on main and its fixed! Thanks for the quick fix!