Ash Update Actions - Filter With Attribute Other Than Primary Key

I have a resource that has a uuid primary key and a lookup value that is hashed on create with the following Hash change. The change takes a specified argument, hashes it and sets it to the specified attribute like so:

create :create do
  argument :lookup, :binary
  accept [additional fields...]
  primary? true

  change {Changes.Hash, argument: :lookup, attribute: :hashed_lookup}
end

This works fine. I also have a read action that hashes the lookup and uses that in a filter so I can read by an unhashed lookup like so:

read :read_by_lookup do 
  argument :lookup, :binary

  prepare {Preparations.Hash, argument: lookup, attribuite: hashed_lookup}
end

However, I’m not sure how to best update a single record when I only have the lookup value, I don’t have the UUID.

A bulk update works and I can re-use the query from the lookup read action:

Ash.Query.for_read(:read_by_lookup, %{lookup: "lookup value"}) 
|> Ash.bulk_update(:update, %{data: %{...}})

But just doing a regular update doesn’t work without first doing a read to get the resource so it has the UUID. If I had the UUID I could go:

%Resource{id: "some uuid"}
|> Ash.Changeset.for_update(:update, %{data: %{...}})
|> Ash.update()

But this doesn’t seem to work:

update :by_lookup do
  argument :lookup, :binary
  accept [additional fields...]

  change fn changeset, _context ->
    lookup = Ash.Changeset.get_argument(changeset, :lookup)
    hash = hash(lookup)
    
    changeset
    |> Ash.Changeset.filter(expr(hashed_lookup == ^hash))
  end
end

Calling the update action:

%Resource{}
|> Ash.Changeset.for_update(:by_lookup, %{lookup: "lookup_value", data: %{...}})
|> Ash.update!()

Throws:

** (Ash.Error.Unknown)
Bread Crumbs:
> Exception raised in: Resource.by_lookup
Unknown Error
* ** (ArgumentError) nil given for :id. Comparison with nil is forbidden as it is unsafe. Instead write a query with is_nil/1, for example: is_nil(s.id)

I tried a few other things but it looks like for_update is expecting the resource to have an :id set for filtering. Is there a way that I can create a regular update action that can update based on a field other than id without having to make a trip to the db for a read first? And is there an more “idiomatic” way to have the lookup automatically hashed?

:thinking: its an interesting question. I don’t think that you can right now. You have a few options to make it easier. The first is that if you are using code interfaces, you can define one that takes the argument and does not require a record to be given, like so:

define :update_thing_by_lookup, args: [:lookup], require_reference?: false, action: :by_lookup

This is essentially shorthand for your bulk update call.

Then you’d have YourDomain.update_thing_by_lookup(your_binary, %{field1: ..., field2: ..}) (naming might be different if you define it on the resource).

I don’t think we would want to support this kind of semantics:

%Resource{}
|> Ash.Changeset.for_update(:by_lookup, %{lookup: "lookup_value", data: %{...}})
|> Ash.update!()

because it could easily be deceptive. Imagine you instead did:

some_actual_but_irrelevant_record
|> Ash.Changeset.for_update(:by_lookup, %{lookup: "lookup_value", data: %{...}})
|> Ash.update!()

It looks like you’d be updating some_actual_but_irrelevant_record but it is actually completely unused. So this particular case “updating a record that you don’t currently actually have” is partially what bulk updates exist for. So you should either use the bulk update call to do it, or the code interface short hand.

That makes sense, thanks!

I was a little confused by the wording under Using the code interface where it says

If the action is an update or destroy, it will take a record or a changeset as its first argument.

I read that and didn’t look at the options for Ash.Resource.Interface since I thought the code interfaces would have to have a record or changeset already. I didn’t realize it could be configured to use similar logic to the bulk update.

I ended up implementing a code interface like this:

update :update_by_lookup do
  argument :lookup, :binary
  accept [...]
  
  change {Changes.HashFilter, argument: :lookup}
end

code_interface do 
  define :update_by_lookup do
    args [:lookup]
    action :update_by_lookup
    require_reference? false
  end
end

Which I can just call like: Resource.update_by_lookup("lookup", %{data...})

Thanks for your time!

1 Like