Manage_relationships not matching existing relationship

Let me preface this all with: I’m sorry this is v2 code. I don’t have much expectation of support for it and upgrading to v3 is definitely on my todo list.

I have a resource called Role that I would like to set a locked status with respect to an actor’s company_id. For reasons, I want to keep track of this outside of the current table so I’m not going to e.g. add a company_id column and a locked (bool) column. Instead I would like to create a resource LockedStatus that just has a role_id and company_id. If the actor’s company_id shows up for a given role_id, it means that role is locked for that actor’s company.

My LockedStatus resource looks like this

defmodule MyApp.LockedStatus do
// ...
  attributes do
    attribute(:company_id, :integer, primary_key?: true, allow_nil?: false)
  end

// ...
  relationships do
    belongs_to(:role, Role, primary_key?: true, allow_nil?: false)
  end

/ ...
end

I’m trying to update it by adding a locked argument to an update action I have on Role, which looks something like this

defmodule MyApp.Role do
// ...
  relationships do
    has_many(:locked_statuses, LockedStatus)
  end

  calculations do
    calculate(
      :locked_status,
      :boolean,
      expr(exists(locked_statuses, company_id == ^actor(:company_id)))
    )
  end

  actions do
    create :my_create do
      argument(:locked, :boolean, allow_nil?: true)
      change(&update_locked_status/2)
    end
  end

  defp update_locked_status(
     %{arguments: %{locked_status: true}, changeset,
     %{actor: actor}
     ) do
    changeset
    |> Ash.Changeset.manage_relationship(
      :locked_statuses,
      %{company_id: actor.company_id},
      on_no_match: :create,
    )
  end

  defp update_locked_status(%{arguments: %{locked_status: false}} = changeset, %{actor: actor}) do
    changeset
    |> Ash.Changeset.manage_relationship(
      :locked_statuses,
      %{company_id: actor.company_id},
      on_match: {:destroy, :destroy}
    )
  end

  defp update_locked_status(changeset, _), do: changeset)
end

For the most part, everything works. However, if I try to lock something that is already locked, i.e. create a relationship that already exists, I get a (Ash.Error.Invalid) Invalid input Invalid value provided for company_id: has already been taken error.

There are a lot of ways I’ve tried to get around this error, like passing the role_id in the input so the lookup works correctly, but it always seems to be falling through to the on_missing clause.

I feel like there is something essential I’m missing about manage_relationship. How would you create & destroy these records on the LockedStatus resource?

Try adding the role_id into the input, and then add the use_identities

  |> Ash.Changeset.manage_relationship(
      :locked_statuses,
      %{company_id: actor.company_id, role_id: user.role_id},
      on_no_match: :create,
      use_identities: [:unique_role_company] # include the identity that makes company and role id here
    )

I added the role_id into the input and specified use_identities but I’m still getting the same error :slightly_frowning_face:

The identity definition looks like this

identity :unique_role_company, [:company_id, :role_id],
  message: "A company can only have one locked status per role"

and the resulting changeset looks like this

#Ash.Changeset<
  action_type: :update,
  action: :update_with_permissions,
  attributes: %{updated_by_id: [redacted], updated_by_name: nil},
  relationships: %{
    locked_statuses: [
      {[%{company_id: [redacted], role_id: [redacted]}],
       [
         ignore?: false,
         on_missing: :ignore,
         on_match: :ignore,
         on_lookup: :ignore,
         eager_validate_with: false,
         authorize?: true,
         on_no_match: :create,
         use_identities: [:unique_role_company],
         meta: [inputs_was_list?: false]
       ]}
    ]
  },
  arguments: %{locked_status: true, permissions: []},
  errors: [],
  data: #MyApp.Role<
    locked_status: #Ash.NotLoaded<:calculation>,
    default: #Ash.NotLoaded<:calculation>,
    locked_statuses: #Ash.NotLoaded<:relationship>,
    permissions: #Ash.NotLoaded<:relationship>,
    features: #Ash.NotLoaded<:relationship>,
    features_join_assoc: #Ash.NotLoaded<:relationship>,
    __meta__: #Ecto.Schema.Metadata<:loaded, "roles">,
    id: [redacted],
    company_id: [redacted],
    name: "testing locked status",
    created_by_id: [redacted],
    created_by_name: [redacted],
    updated_by_id: nil,
    updated_by_name: nil,
    locked: false,
    created_at: ~U[2024-10-17 23:40:19.814323Z],
    updated_at: ~U[2024-10-18 21:13:54.575552Z],
    aggregates: %{},
    calculations: %{},
    ...
  >,
  valid?: true
>

So given that it’s 2.0 code there is only so much spelunking that I can do into the internals really. With that said, manage_relationship isn’t actually doing very much for you here. You could just create/update these rows manually and end up with something simpler and not have to worry if this is something fixed in later versions or not. You can use an after_action hook and call resource actions to upsert/otherwise alter the related resource.