Reading resource instances as part of creating a new instance

I’m trying to hack together an application that runs tournaments: players can join tournaments, 1-on-1 matches are played between the tournament participants and they get rated based on the results.

Since this application is my first usage of Ash, there are many things that are not yet clear to me. At the moment, I’ve gotten to the point where the most important resources have gotten either skeleton or basic implementations, but I’ve hit a point where I can’t quite figure out how I would write some functionality in an “idiomatic Ash way”.

Code

The code that I haven’t figured out how to write is located in the function create_ratings_for_match_result_participants/1. The rest of the Rating module is rather basic Ash code.

defmodule Cassis.Tournaments.Rating do
  use Ash.Resource,
    domain: Cassis.Tournaments,
    data_layer: AshPostgres.DataLayer

  require Ash.Resource.Attribute.Helpers

  postgres do
    table "ratings"
    repo Cassis.Repo
  end

  actions do
    defaults [:read]

    create :create do
      accept [:type, :value]
      # accept [:type]

      argument :participant, :map do
        allow_nil? false
      end

      argument :result, :map do
        allow_nil? false
      end

      change manage_relationship(:participant, type: :append_and_remove)
      change manage_relationship(:result, type: :append_and_remove)
    end
  end

  attributes do
    uuid_primary_key :id
    timestamps()

    attribute :value, :float, allow_nil?: false

    attribute :type, :atom do
      allow_nil? false
      constraints one_of: [:elo]
      default :elo
    end
  end

  relationships do
    belongs_to :participant, Cassis.Tournaments.Participant
    belongs_to :result, Cassis.Tournaments.Result
  end

  def create_ratings_for_match_result_participants(%Result{} = result) do
    result = Ash.load!(result, match: [:participants])
    [match_participant_a, match_participant_b] = result.match.participants

    [
      participant_a_rating_value,
      participant_b_rating_value
    ] =
      Enum.map(
        [
          match_participant_a,
          match_participant_b
        ],
        &latest_rating_value_or_generate_default_value/1
      )

    a_vs_b_outcome =
      cond do
        result.draw? -> :draw
        result.winner.id == match_participant_a.id -> :win
        result.loser.id == match_participant_a.id -> :loss
      end

    {
      new_participant_a_rating_value,
      new_participant_b_rating_value
    } =
      Elo.rate(
        participant_a_rating_value,
        participant_b_rating_value,
        a_vs_b_outcome
      )

    __MODULE__
    |> Ash.Changeset.for_create(:create, %{
      type: :elo,
      value: new_participant_a_rating_value,
      result: result,
      participant: match_participant_a
    })
    |> Ash.create!()

    __MODULE__
    |> Ash.Changeset.for_create(:create, %{
      type: :elo,
      value: new_participant_b_rating_value,
      result: result,
      participant: match_participant_b
    })
    |> Ash.create!()

    :ok
  end

  defp latest_rating_value_or_generate_default_value(match_participant) do
    Rating
    |> Ash.Query.filter(participant_id == ^match_participant.id)
    |> Ash.Query.sort(inserted_at: :desc)
    |> Ash.Query.limit(1)
    |> Ash.read!()
    |> case do
      [latest_rating] -> latest_rating.value
      [] -> default_elo_rating()
    end
  end

  defp default_elo_rating, do: 1200
end

Comments

  1. The current version of the create :create action is accepting a :value, but the intention is that the value should only be set through the procedure described in create_ratings_for_match_result_participants. In other words: accept [:type, :value] should be accept [:type], but I’m not there yet, and I need value for testing (I guess I could have a separate action that allows this).
  2. constraints one_of: [:elo] is there because at some point I’m adding another rating algorithm.

Questions

  1. What would be the best way to transform the logic of create_ratings_for_match_result_participants to an idiomatic Ash action?
  2. Considering the resources that are being called in create_ratings_for_match_result_participants, is it best to place the resulting action in Rating, or maybe in one of the other resources, e.g. Result?
  3. Any obvious problems with the code that maybe also need to be addressed?
  1. The easiest way would probably be to just use a generic action to hold the logic.
defmodule Cassis.Tournaments.Rating do
  use Ash.Resource,
    domain: Cassis.Tournaments,
    data_layer: AshPostgres.DataLayer

  require Ash.Resource.Attribute.Helpers

  postgres do
    table "ratings"
    repo Cassis.Repo
  end

  code_interface do
   # e.g only define the generic action because the 
   # create itself should not be used from the outside
    define :create_ratings_for_result, args: [:result]
   end

  actions do
    defaults [:read]

   action :create_ratings_for_result, {:array, :string} do
     #maybe do everything in a transaction
     transaction? true

     argument :result, :struct do
       constrains instance_of: Result
      end
      run Cassis.Trournaments.Rating.CreateRatingsForResult
   end

    create :create do
      accept [:type, :value]

      argument :participant, :map do
        allow_nil? false
      end

      argument :result, :map do
        allow_nil? false
      end

      change manage_relationship(:participant, type: :append_and_remove)
      change manage_relationship(:result, type: :append_and_remove)
    end
  end

  attributes do
    uuid_primary_key :id
    timestamps()

    attribute :value, :float, allow_nil?: false

    attribute :type, :atom do
      allow_nil? false
      constraints one_of: [:elo]
      default :elo
    end
  end

  relationships do
    belongs_to :participant, Cassis.Tournaments.Participant
    belongs_to :result, Cassis.Tournaments.Result
  end
end

defmodule Cassis.Trournaments.Rating.CreateRatingsForResult do
  use Ash.Ash.Resource.Actions.Implementation

  alias Cassis.Tournaments.Rating

  @impl true
  def run(input, _opts, context) do
   %Result{} = result = Ash.ActionInput.get_argument(input, :result)
    
    opts = Ash.Context.to_opts(context)

    result = Ash.load!(result, match: [:participants], opts)
    [match_participant_a, match_participant_b] = result.match.participants

    [
      participant_a_rating_value,
      participant_b_rating_value
    ] =
      Enum.map(
        [
          match_participant_a,
          match_participant_b
        ],
        &latest_rating_value_or_generate_default_value(&1, opts)
      )

    a_vs_b_outcome =
      cond do
        result.draw? -> :draw
        result.winner.id == match_participant_a.id -> :win
        result.loser.id == match_participant_a.id -> :loss
      end

    {
      new_participant_a_rating_value,
      new_participant_b_rating_value
    } =
      Elo.rate(
        participant_a_rating_value,
        participant_b_rating_value,
        a_vs_b_outcome
      )

    Rating
    |> Ash.Changeset.for_create(:create, %{
      type: :elo,
      value: new_participant_a_rating_value,
      result: result,
      participant: match_participant_a
    })
    |> Ash.create!()

    Rating
    |> Ash.Changeset.for_create(:create, %{
      type: :elo,
      value: new_participant_b_rating_value,
      result: result,
      participant: match_participant_b
    })
    |> Ash.create!()

    :ok
  end

  defp latest_rating_value_or_generate_default_value(match_participant, opts) do
    Rating
    |> Ash.Query.filter(participant_id == ^match_participant.id)
    |> Ash.Query.sort(inserted_at: :desc)
    |> Ash.Query.limit(1)
    |> Ash.Query.for_read(:read, %{}, opts)
    |> Ash.read!()
    |> case do
      [latest_rating] -> latest_rating.value
      [] -> default_elo_rating()
    end
  end

  defp default_elo_rating, do: 1200
end
  1. I think having it on the rating this way could be okay. But I’m not too familiar with how games of this kind work. So if it is something that should always be done as part of finishing a game, it could be part of creating the result. However, in that case, it could also be okay to have the logic live here and just call the generic action from there. So do what feels best to you.

  2. I think in most cases you want to call Ash.Query.for_read somewhere when building query. I added that in the example. Otherwise, looks good to me :wink:

1 Like

Thank you very much for your help! Your code example and advice have pushed me forward significantly!