How to use conditions on creation and destory in Ash

Hi I’m trying to create a simple action to recommend and unrecommend.

For now logic that a person can only recommend one post at a time is implemented using identities.

thumb_up.ex

defmodule Dentallog.DentalLab.ThumbUp do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    extensions: [
      AshGraphql.Resource
    ]

  graphql do
    type :dental_lab_post_thumb_up

    mutations do
      create :thumb_up_dental_lab_post, :thumb_up
      destroy :cancel_thumb_up_dental_lab_post, :cancel
    end
  end

  actions do
    defaults [:read]

    create :thumb_up do
      change relate_actor(:user)
    end

    destroy :cancel do
      change relate_actor(:user)
    end
  end

  attributes do
    uuid_primary_key :id

    create_timestamp :created_at
    update_timestamp :updated_at
  end

  relationships do
    belongs_to :user, Dentallog.Accounts.User do
      api Dentallog.Accounts
    end

    belongs_to :dental_lab_post, Dentallog.DentalLab.DentalLabPost do
      allow_nil? false
      attribute_writable? true
    end
  end

  postgres do
    table "dental_lab_thumb_ups"
    repo Dentallog.Repo
  end

  identities do
    identity :unique_thumbup, [:dental_lab_post_id, :user_id] do
      eager_check_with Dentallog.DentalLab
    end
  end
end

But I’m not sure what to do about the logic for canceling.
I guess I could use a changeset, but that’s a bit difficult.
Currently, the PK works this: if you enter an ID, it gets deleted.
But I’d like to get the input as the ID of the post and authenticate the user via relate_actor(:user). (post_id, user_id)

Is there a good way to do this?

Also, I’m wondering if you recommend using identities or atomic to allow for guarantee one-time recommendations.

You can do that with bulk destroy actions. We will be working on guides that explain how to use bulk actions more than what exists currently. But you can do this:

read :my_thumb_ups do
  filter expr(id == ^actor(:id))
end
ThumbUp
|> Ash.Query.for_read(:my_thumb_ups, %{}, actor: actor)
|> Ash.Query.filter(dental_lab_post.id == ^post_id)
|> ThumbUp.bulk_destroy!(:destroy)

You can put this behind a generic action:

action :remove, :atom do
  constraints one_of: [:ok]
  argument :post_id, :uuid, allow_nil?: false

  run fn input, context -> 
    ThumbUp
    |> Ash.Query.for_read(:my_thumb_ups, %{}, Ash.context_to_opts(context))
    |> Ash.Query.filter(dental_lab_post.id == ^input.post_id)
    |> ThumbUp.bulk_destroy!(:destroy)

   {:ok,  :ok}
  end
end

And then you can put that in your code_interface:

code_interface do
  define_for YourApi
  define :remove, args: [:post_id]
end

Then you can call it:

ThumbUp.remove!(post.id)
1 Like

Thank you for your answer, Jack!
I think I’m getting better at ash thanks to you.

Sorry, this may be off topic, but I have one more question.
I would like to expose the action with AshGraphql.
Do you happen to know how to do that for actions?

I looked in the documentation and didn’t find it.

...
graphql do
    type :dental_lab_post_thumb_up

    actions do
      action :cancel_thumb_up, :cancel
    end

    mutations do
      create :thumb_up_dental_lab_post, :thumb_up
    end
  end
...

I’m not familiar with absinthe yet, and I was wondering if I should use schema.ex to do this?

Generic actions are exposed as either mutations or queries. You ultimately decide which one it should be, in this case a mutation makes sense since it is making a change in the action.

graphql do
    type :dental_lab_post_thumb_up

    mutations do
      create :thumb_up_dental_lab_post, :thumb_up
      action :cancel_thumb_up, :cancel
    end
  end

On my phone , but IIRC that is how it’s done :slight_smile:

When I do that, I got an error.
Do you happen to know the cause of the error?

** (Absinthe.Schema.Error) Compilation failed:
---------------------------------------
Location ## Location.
/users/dwlee/Documents/dentallog/dentallog-server/deps/ash_graphql/lib/resource/resource.ex:599

In the cancel_thumb-up field, :dental_lab_post_thumb_up_cancel is not defined in the schema.

If it is referenced, the type must exist.


    (Absinthe 1.7.6) lib/absinthe/schema.ex:392: Absinthe.Schema.__after_compile__/2
    (stdlib 5.2) lists.erl:1594: :lists.foldl/3
    (Elixir 1.16.1) lib/kernel/parallel_compiler.ex:428: anonymous fn/5 in Kernel.ParallelCompiler.spawn_workers/8

This looks like a bug :frowning: What happens if you define it in the query block instead? (thats not the answer, but will help me debug)

...
graphql do
    type :dental_lab_post_thumb_up

    queries do
      action :cancel_thumb_up, :cancel
    end

    mutations do
      create :thumb_up_dental_lab_post, :thumb_up
    end
  end
...
== Compilation error in file lib/dentallog/schema.ex ==
** (Absinthe.Schema.Error) Compilation failed:
---------------------------------------
## Locations
/Users/dwlee/Documents/dentallog/dentallog-server/deps/ash_graphql/lib/resource/resource.ex:524

In field Cancel_thumb_up, :dental_lab_post_thumb_up_cancel is not defined in your schema.

Types must exist if referenced.


    (absinthe 1.7.6) lib/absinthe/schema.ex:392: Absinthe.Schema.__after_compile__/2
    (stdlib 5.2) lists.erl:1594: :lists.foldl/3

I’m having the same issue

Ah, okay, I see the problem.

1 Like

I’ve fixed the issue in main, but I’d suggest changing your action in such a way that it will be better anyway, and will avoid the issue. If you make your action return the id of the post that was deleted

action :remove, :string do
  argument :post_id, :uuid, allow_nil?: false

  run fn input, context -> 
    ThumbUp
    |> Ash.Query.for_read(:my_thumb_ups, %{}, Ash.context_to_opts(context))
    |> Ash.Query.filter(dental_lab_post.id == ^input.post_id)
    |> ThumbUp.bulk_destroy!(:destroy)

   {:ok,  input.post_id}
  end

it should work :slight_smile:

1 Like

To be clear, the issue is with using :atom and constraints: :one_of, so any other return type would avoid the issue (which should be fixed soon). You could even return the post that was destroyed.

action :remove, :struct do
  constraints instance_of: __MODULE__
  argument :post_id, :uuid, allow_nil?: false

  run fn input, context -> 
    ThumbUp
    |> Ash.Query.for_read(:my_thumb_ups, %{}, Ash.context_to_opts(context))
    |> Ash.Query.filter(dental_lab_post.id == ^input.post_id)
    |> ThumbUp.bulk_destroy(:destroy, return_records?: true, return_errors?: true)
    |> case do
      %Ash.BulkResult{status: :success, records: [destroyed]} ->
        {:ok, destroyed}

      %Ash.BulkResult{status: :success, records: []} ->
        {:ok, nil}

      %Ash.BulkResult{status: :error, errors: errors} ->
        {:error, errors}
    end
  end

Also shown in this example is a way to manually return errors if desired, as opposed to raising.

1 Like

Thank you for your response. Jack!
It’s working fine.

graphql do
    type :dental_lab_post_thumb_up

    mutations do
      create :thumb_up_dental_lab_post, :thumb_up
      action :cancel_thumb_up, :cancel
    end
  end
...

read :my_thumb_ups do
      filter expr(user_id == ^actor(:id))
    end

action :cancel, :string do
      argument :post_id, :uuid, allow_nil?: false

      run fn input, context ->
        Dentallog.DentalLab.ThumbUp
        |> Ash.Query.for_read(:my_thumb_ups, %{}, Ash.context_to_opts(context))
        |> Ash.Query.filter(dental_lab_post_id == ^input.arguments.post_id)
        |> Dentallog.DentalLab.read!()
        |> Dentallog.DentalLab.bulk_destroy!(:destroy,
          return_records?: true,
          return_errors?: true
        )

        {:ok, input.arguments.post_id}
      end
    end
  end
1 Like

This is only tangentially related to your original question but I would recommend that you do in fact return the deleted post. This way you can automagically update your client cache (if you are using apollo as an example):

mutation myDeleteMutation($id: ID!) {
  deleteSomething(id: $id) {
    id
    author {
        id
        posts {
            id  // <- this line here will tell apollo to remove the post from the list of posts in the authors cache
        }
    }
  }
}

this way you dont have to do manual cache manipulations. Obviously this doesn’t really scale to thousands of rows. But for things where the total number of rows will always remain small this works really nicely.

2 Likes

Thanks for sharing good information

1 Like