Shortcut for Ash.Changeset.change_attribute ... get_attribute combo

Hi,

I’m learning Ash and I’m really enjoying my journey so far! The framework is awesome, and the documentation is pretty thorough. Thank you @zachdaniel!

I have a quick question: is there a shortcut for transforming an attribute, e.g. for the code below?

  change fn changeset, _ ->
        Ash.Changeset.change_attribute(
          changeset,
          :adapter,
          to_string(Ash.Changeset.get_attribute(changeset, :adapter))
        )
      end

I can of course roll out my own macro

transform :adapter &to_string/1

that will expand to the code above, but I’m curious if there a built-in shortcut already?

The context is that I want to do my own cleanup of the incoming data in an e.g. :create action. Am I approaching this correctly?

It depends, there is the atomic_update builtin, but I suspect that isn’t necessarily what you’re looking for (because if all you really needed was a to_string conversion the string type would handle that).

But if you can do your update as an expression, then atomic_update is definitely the best way, i.e

change atomic_update(:foo, foo + 1)

To do this generically with functions, you could build a module change and reference that everywhere, for example:

defmodule YourApp.Changes.UpdateAttribute do
  use Ash.Resource.Change

  def change(changeset, opts, _) do
    current = Ash.Changeset.get_attribute(changeset, opts[:attribute])
    new = opts[:fun].(current)
    Ash.Changeset.change_attribute(changeset, opts[:attribute], new)
  end
end

In your resource:

change {YourApp.Changes.UpdateAttribute, attribute: :foo, &__MODULE__.fun/1}

With the above formulation, you have to use fully qualified functions. If you define a macro:

defmodule MyApp.Changes do
    defmacro update_attribute(attr, callback) do
    {value, function} =
      Spark.CodeHelpers.lift_functions(callback, :change_update_attribute, __CALLER__)

    quote generated: true do
      unquote(function)

      {YourApp.Changes.UpdateAttribute,
       callback: unquote(value), attribute: unquote(attr)}
    end
  end
end

then in your resource you could do

import MyApp.Changes

...

change update_attribute(:attr, fn v -> stuff end)

I’d also accept a PR that added this to the core built-in changes :slight_smile:

2 Likes

Thank you Zach for such a quick reply!

I missed that there are built-in type conversions, so for my immediate need (converting an atom to a string to store it in the database) I guess I don’t need anything at all.

However, generally speaking, I’m thinking of a use case when :create action gets an argument that cannot be simply converted to the target type. E.g. imagine we get a map as the input for the create action and only need a specific value from it. This is different from atomic_update which transforms expressions that are already in the database in the correct form.

One can argue that it should be the responsibility of the caller to do the value cleaning upfront, and maybe that’s the right approach, esp. given that Ash does the type conversion out of the box. I have to think about this some more.

Regarding the PR - will happy to do that if/when I better understand my own use case, and I still need to learn more about Ash internals as Spark.CodeHelpers.lift_functions sounds like Greek to me.

TBH its about as close to greek as it gets in Elixir-land :laughing: Its a tool for supporting anonymous functions in DSLs like that that typically one would not need to use.

As for supporting arguments that must be massaged, the general pattern there is to use arguments.

argument :thing, :string, allow_nil?: false

change fn changeset, _ -> 
  case convert_thing(changeset.arguments.thing) do
    ....
  end
end
2 Likes

Hi Zach,

I needed to transform an argument again, and after looking at your code some more, it does look less scary. Couple of questions I would like to clarify in order for me to work on a PR:

  1. In your example of UpdateAttribute usage, the call in the resource should probably be

    change {YourApp.Changes.UpdateAttribute, attribute: :foo, fun: &__MODULE__.fun/1}`
    

    (your example was missing a keyword argument before the function name).

  2. In the macro example, callback should probably be fun to make it consistent with the UpdateAttribute above, correct?

  3. I think I roughly understand what lift_functions does: it returns a value of the newly generated function (that can be used in fun.() calls) as well as its definition, which the macro inserts right into the module? However, I’m not sure what’s the meaning of the :change_update_attribute - the doc string for lift_functions simply refers to it as atom and doesn’t display what it does.

  4. I’m also unsure about the placement of unquote(function) - wouldn’t that call place it right after the change, so we’ll get something like

    change <whatever unquote(function) produced> {YourApp.Changes.UpdateAttributes ...}`
    

    inserted while we probably want

    change {YourApp.Changes.UpdateAttributse ...}
    

    and then the definition of the function somewhere outside of this code block? Or am I missing a point completely?

And a related question: I understand that change is not supported in Read actions. What is the recommended way to transform arguments there?

Here is an example: I have an externally provided %SomeModule.LLM{} structure, and I want to persist in the datastore. I created a parallel Ash resource with a subset of attributes, so for example I can do the following

# llm is an instance of %SomeModule.LLM{}
attributes = llm |> Map.take(["... subset of attributes I'm persisting ..."])
MyApp.Resources.LLM |> Ash.Changeset.for_create(:creare, attributes) |> Ash.create!

to create a new resource. However, since the LLM will be used as a reference in other places, I don’t want to create duplicate resources with the same exact attributes. I can check whether such resource already exist by e.g.

MyApp.Resources.LLM |> Ash.Query.filter(attributes) |> Ash.read_one!()

but I have troubles creating a read action that will accept %SomeModule.LLM{} as an argument and do the filtering of attributes internally, following put everything into actions advice.

Thank you!

  1. yep
  2. yep
  3. yep! :change_update_attribute is an identifier that is placed into the generated function name to make it easier to find if it is ever inspected.
  4. lift functions should do this automatically, or I’m not sure I understand your question.
  1. What’s the purpose of unquote(function) in the macro body expansion?

Oh, right yes that defines the function right there at that spot. But that is the right way to do it as it will point to the same line as the change.

1 Like

Read actions have preparations that are just like changes but for read actions.

read :read do
  prepare fn query, _  -> 
    query
  end
end

You can use arguments in the same way.

1 Like

Indeed, I keep forgetting about the parallels between build for read and change for create/read/destroy actions. Thank you for the reminder!