Auto increment value for position of a new task in a ToDoList

I have the following Task resource. Is there a way to auto increment the position value in case somebody does a App.ToDoList.Task.create!(%{content: "Mow the lawn"}) without providing a value for position?

defmodule App.ToDoList.Task do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "tasks"
    repo App.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :content, :string, allow_nil?: false
    attribute :position, :integer, allow_nil?: false
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.ToDoList
    define :read
    define :create
    define :update
    define :destroy
    define :by_id, get_by: [:id], action: :read
  end
end

Are tasks unique on content? If so, you can do this on upsert.

create :create do
  upsert? true
  upsert_identity :unique_content

  change atomic_update(:position, position + 1)
end

If not, however, you will have to get a bit more clever. Ash doesn’t support (because most data layers don’t support) using upsets on non-unique fields.

1 Like

With that I get the following error:

$ iex -S mix phx.server
Compiling 4 files (.ex)
    error: undefined variable "position"
    │
 24 │       change atomic_update(:position, position + 1)
    │                                       ^^^^^^^^
    │
    └─ lib/app/to_do_list/resources/task.ex:24:39: App.ToDoList.Task (module)


== Compilation error in file lib/app/to_do_list/resources/task.ex ==
** (CompileError) lib/app/to_do_list/resources/task.ex: cannot compile module App.ToDoList.Task (errors have been logged)
    (ash 2.19.3) expanding macro: Ash.Resource.Dsl.Actions.Create.Actions.Changes.Change.change/1

The code I use:

  attributes do
    uuid_primary_key :id
    attribute :content, :string, allow_nil?: false
    attribute :position, :integer, allow_nil?: false
  end

  actions do
    defaults [:read, :update, :destroy]

    create :create do
      upsert? true
      upsert_identity :unique_content

      change atomic_update(:position, position + 1)
    end
  end

Sorry, I mistyped it.

change atomic_update(:position, expr(position + 1))

I’m guessing that Stefan actually wants something like SERIAL or using a sequence in Postgres. With your example, wouldn’t all newly created resources have the value 1 for the position if it even works because the position would be null in that case?

The count aggregate only works on related resource, it might be nice to have it work on the resource it self too like count(self) then it could be something like atomic_change(:position, expr(count(self) + 1))

oh, I see. The upsert would actually update the original with a count + 1, which you’re saying isn’t the idea here. @wintermeyer are you looking to create a new task w/ the count + 1 to distinguish it from the other ones? If so, yeah you’re looking for something different.

I am looking for a way to create a new Task without knowing the next available position. The position should be set by some sort of magic. The first table entry would have the position 1 and ever new entry would be +1. In that scenario tasks wouldn’t be deleted.

And I assume it’s based on something like whatever list the task is in? Like not a globally incrementing integer, but incrementing per list.

Right now it is the simple resource from the top. But it would be interesting to have a solution for both scenarios.

If it’s for an entire table, then it’s relatively easy.

attribute :position, :integer do
  allow_nil? false
  generated? true
end

I’d have to double check but I believe that is all that you need to have ash_postgres generate an auto incrementing column as :bigserial in the database.

However, if you want to do it scoped by a particular column like :list_id, there in’t an elegant way to do that with the database automatically. You will likely be best off implementing it as application logic

create :create do
  # usually better to put these in modules, i.e `change Setposition`,
  # this is inline to be a better example
  change fn changeset, _ -> 
    Ash.Changeset.before_action(changeset, fn changeset -> 
      position = 
        Task
         |> Ash.Query.filter(list_id == ^Ash.Changeset.get_attribute(changeset, :list_id))
         |> Ash.Query.sort(inserted_at: :desc)
         |> Ash.Query.lock(:for_update) # lock it to make this concurrency safe
         |> ToDoList.read_one!()
         |> case do
           nil -> 0
           %{position: position} -> position + 1
         end

       Ash.Changeset.force_change_attribute(changeset, :position, position)
    end)
  end
end

I had an idea that might work.

has_many :self, __MODULE__ do
  no_attributes? true
end

actions do
  create :create do
    change atomic_update(:position, expr(count(self) + 1))
  end
end

You could maybe also solve the second case by adding a query to the expression

change atomic_update(:position, expr(count(self, query: [filter: list_id == "something"]) + 1)

might need to do it in a module to get the list_id or whatever from the passed-in data

Thats…pretty clever :laughing:

has_many :co_list_items, __MODULE__ do
  source_attribute :list_id
  destination_attribute :list_id
end

actions do
  create :create do
    change atomic_update(:position, expr(count(co_list_items) + 1))
  end
end

Ah, right I remembered why I didn’t recommend that originally though. atomic_update changes on create only take effect in the case of an upsert. We don’t have a good way without making upstream improvements to ecto (which I would like to do potentially) to use expressions to determine values when creating.

Note: since items in todo list might be deleted I think it is better to use max instead of count, it will definitely be unique and higher than any previous value:

expr(max(co_list_items, position) + 1)
1 Like

I tried this code:

  actions do
    defaults [:read, :update, :destroy]

    create :create do
      change fn changeset, _ ->
        Ash.Changeset.before_action(changeset, fn changeset ->
          position =
            App.ToDoList.Task
            |> Ash.Query.sort(position: :desc)
            |> Ash.Query.limit(1)
            |> Ash.Query.lock(:for_update)
            |> App.ToDoList.read_one!()
            |> case do
              nil -> 0
              %{position: position} -> position + 1
            end

          Ash.Changeset.force_change_attribute(changeset, :position, position)
        end)
      end
    end
  end

It resulted in this error:

iex(40)> App.ToDoList.Task.create!(%{content: "Buy milk"})
[debug] QUERY OK db=0.4ms idle=726.6ms
begin []
↳ anonymous fn/3 in Ash.Changeset.with_hooks/3, at: lib/ash/changeset/changeset.ex:2404
[debug] QUERY OK source="tasks" db=0.5ms
SELECT t0."id", t0."content", t0."position" FROM "tasks" AS t0 ORDER BY t0."position" DESC FOR UPDATE OF t0 []
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:686
[debug] QUERY OK db=0.5ms
rollback []
↳ anonymous fn/3 in Ash.Changeset.with_hooks/3, at: lib/ash/changeset/changeset.ex:2404
** (Ash.Error.Invalid) Input Invalid

* expected at most one result but got at least 2.
      
      Please ensure your action is configured with an appropriate filter to ensure a single result is returned.
  (elixir 1.16.0) lib/process.ex:860: Process.info/2
  (ash 2.19.3) lib/ash/error/exception.ex:59: Ash.Error.Invalid.MultipleResults.exception/1
  (ash 2.19.3) lib/ash/api/api.ex:2637: Ash.Api.unwrap_one/1
  (ash 2.19.3) lib/ash/api/api.ex:2619: Ash.Api.unwrap_one/1
  (ash 2.19.3) lib/ash/api/api.ex:2583: Ash.Api.read_one/3
  (ash 2.19.3) lib/ash/api/api.ex:2569: Ash.Api.read_one!/3
  (app 0.1.0) lib/app/to_do_list/resources/task.ex:32: anonymous fn/1 in App.ToDoList.Task.change_0_generated_C8BC88BD834A7AACE31008D1AFF809B1/2
  (ash 2.19.3) lib/ash/changeset/changeset.ex:2692: anonymous fn/2 in Ash.Changeset.run_before_actions/1
  (elixir 1.16.0) lib/enum.ex:4842: Enumerable.List.reduce/3
  (elixir 1.16.0) lib/enum.ex:2582: Enum.reduce_while/3
  (ash 2.19.3) lib/ash/changeset/changeset.ex:2669: Ash.Changeset.run_before_actions/1
  (ash 2.19.3) lib/ash/changeset/changeset.ex:2806: Ash.Changeset.run_around_actions/2
  (ash 2.19.3) lib/ash/changeset/changeset.ex:2406: anonymous fn/3 in Ash.Changeset.with_hooks/3
...

Something definitely seems off there. If updating ash/ash_postgres to latest versions doesn’t fix it, then I think it’s a bug of some kind. Try removing Ash.Query.lock. Even if that doesn’t solve it, there is a bug. Please open an issue and I will address as soon as I have a chance.