Update belongs_to record through managed association

Hey :wave: After almost two days of trying to understand what I’m doing wrong, I’m finally asking for help.

So I have two very basic resources, User and Profile as follows:

User

attributes do
  integer_primary_key :id

  attribute :firstname, :string, allow_nil?: false
  attribute :lastname, :string, allow_nil?: false

  attribute :created_from_profile, :boolean, default: false
end

Profile

attributes do
  integer_primary_key :id

  attribute :contact_email, :string, allow_nil?: false
end

relationships do
  belongs_to :user, AshRepro.Accounts.User do
    api AshRepro.Accounts
    attribute_type :integer
  end
end

What I am trying to achieve is manage my User through its Profile. I have no problem on the creation side, but I cannot make the update work.

Here’s the relevant code:

User

actions do
  defaults([:read, :destroy])

  create :create_from_profile do
    change set_attribute(:created_from_profile, true)
  end

  update :update_from_profile do
    reject [:created_from_profile]
  end
end

Profile

actions do
  defaults [:read]

  create :create do
    primary? true

    argument :user, :map do
      allow_nil? false
    end

    change manage_relationship(:user,
              on_no_match: {:create, :create_from_profile},
              on_match: :error,
              on_missing: :error
            )
  end

  update :update do
    primary? true

    argument :user, :map do
      allow_nil? false
    end

    change manage_relationship(:user,
              on_match: {:update, :update_from_profile},
              on_no_match: :error,
              on_missing: :error
            )
  end
end

Here’s how I create both resources through the managed relationship, which works just fine:

profile =
  Profile
  |> Ash.Changeset.for_create(:create, %{
    contact_email: "john@doe.com",
    user: %{firstname: "John", lastname: "Doe"}
  })
  |> Profiles.create!()

But then when I try to update the user (still through the managed relationship), although the user in the returned profile is correct, it’s not updated in the database, only the profile is:

profile
|> Ash.Changeset.for_update(
  :update,
  %{
    contact_email: "updated@updated.com",
    user: %{profile.user | firstname: "UPDATED", lastname: "UPDATED"}
  }
)
|> Profiles.update!()

I also tried passing only the user key as a parameter, which didn’t change anything.

One thing I’ve noticed is that the changeset inside the :update_from_profile action doesn’t have any attribute set, the changes are in the data field, which if I’m correct means Ash will never update the user since it doesn’t detect any “staged changes”:

#Ash.Changeset<
  action_type: :update,
  action: :update_from_profile,
  attributes: %{},
  relationships: %{},
  errors: [],
  data: #AshRepro.Accounts.User<
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 4,
    firstname: "UPDATED",
    lastname: "UPDATED",
    created_from_profile: true,
    inserted_at: ~U[2024-04-03 17:07:37.666173Z],
    updated_at: ~U[2024-04-03 17:07:37.666173Z],
    aggregates: %{},
    calculations: %{},
    ...
  >,
  context: %{
    authorize?: false,
    actor: nil,
    accessing_from: %{name: :user, source: AshRepro.Profiles.Profile}
  },
  valid?: true
>

I’m sure I’m missing something simple, but it seems I can’t figure out what it is on my own. I would greatly appreciate any help!

I have created a minimal reproduction example to make sure I wasn’t doing something weird in my real codebase (e.g. I’m migrating from Ecto and I have not yet generated+adapted+run the Ash migrations), so I’ll share it here in case it helps:

Hmm…I’m not quite sure what the issue is, but can you try passing a regular map and not a struct?

profile
|> Ash.Changeset.for_update(
  :update,
  %{
    contact_email: "updated@updated.com",
    user: %{firstname: "UPDATED", lastname: "UPDATED"}
  }
)
|> Profiles.update!()
1 Like

That’s the way I tried to do it at first, but I get the following error. Looks like it’s not reading the id from the profile but instead from the parameters?

** (Ash.Error.Invalid) Input Invalid
     
     * Invalid value provided for user: changes would create a new related record.
       (elixir 1.15.7) lib/process.ex:860: Process.info/2
       (ash 2.21.6) lib/ash/error/exception.ex:59: Ash.Error.Changes.InvalidRelationship.exception/1
       (ash 2.21.6) lib/ash/actions/managed_relationships.ex:422: Ash.Actions.ManagedRelationships.create_belongs_to_record/7
       (elixir 1.15.7) lib/enum.ex:4830: Enumerable.List.reduce/3
       (elixir 1.15.7) lib/enum.ex:2564: Enum.reduce_while/3
       (ash 2.21.6) lib/ash/actions/managed_relationships.ex:90: Ash.Actions.ManagedRelationships.setup_managed_belongs_to_relationships/3
       (ash 2.21.6) lib/ash/changeset/changeset.ex:2833: anonymous fn/2 in Ash.Changeset.run_before_actions/1
       (elixir 1.15.7) lib/enum.ex:4830: Enumerable.List.reduce/3
       (elixir 1.15.7) lib/enum.ex:2564: Enum.reduce_while/3
       (ash 2.21.6) lib/ash/changeset/changeset.ex:2810: Ash.Changeset.run_before_actions/1
       (ash 2.21.6) lib/ash/changeset/changeset.ex:2947: Ash.Changeset.run_around_actions/2
       (ash 2.21.6) lib/ash/changeset/changeset.ex:2547: anonymous fn/3 in Ash.Changeset.with_hooks/3
       (ecto_sql 3.11.1) lib/ecto/adapters/sql.ex:1358: anonymous fn/3 in Ecto.Adapters.SQL.checkout_or_transaction/4
       (db_connection 2.6.0) lib/db_connection.ex:1710: DBConnection.run_transaction/4
       (ash 2.21.6) lib/ash/changeset/changeset.ex:2545: anonymous fn/3 in Ash.Changeset.with_hooks/3
       (ash 2.21.6) lib/ash/changeset/changeset.ex:2684: anonymous fn/2 in Ash.Changeset.transaction_hooks/2
       (ash 2.21.6) lib/ash/changeset/changeset.ex:2526: Ash.Changeset.with_hooks/3
       (ash 2.21.6) lib/ash/actions/update/update.ex:355: Ash.Actions.Update.commit/3
       (ash 2.21.6) lib/ash/actions/update/update.ex:230: Ash.Actions.Update.do_run/4
       (ash 2.21.6) lib/ash/actions/update/update.ex:189: Ash.Actions.Update.run/4

Adding the ID to the map worked! Thank you so much Zach!

profile
|> Ash.Changeset.for_update(:update,
  user: %{id: profile.user.id, firstname: "UPDATED", lastname: "UPDATED"}
)
|> Profiles.update!()
1 Like

There’s still the issue that ash_phoenix is submitting the form incorrectly if that’s the way to do it (that was my initial issue, and IIRC it tries to pass the struct, not a map).

I’ll dig deeper tomorrow, probably open an issue on ash_phoenix’s repository, and try to make a PR if that’s deemed necessary.

At least in the meantime I now know how to implement a workaround, thanks again!

huh, that is pretty strange. I don’t see why AshPhoenix would be submitting the struct. Are you using the newer <.inputs or the inputs_for(...)?

1 Like

I’m using <.inputs_for as specified in the “working with related data” documentation from ash_phoenix. I tried to search for <.inputs / inputs in docs & Google but it yielded no result, could you please point me to the right place?

All good, you’re using the right thing. That sets hidden fields automatically, which is what I wanted to make sure was being done. Just prior to submitting your form, you can do this:

AshPhoenix.Form.params(your_form)

to see what its going to send to the action. Can you show me that? Also, when creating your forms, are you using forms: [auto?: true] or are you manually configuring the nested forms?

Here’s the result from the AshPhoenix.Form.params/2:

%{
  "_form_type" => "update",
  "_touched" => "contact_email,user",
  "contact_email" => "updated@updated.com",
  "id" => 4,
  "user" => %{
    "_form_type" => "update",
    "_persistent_id" => "0",
    "_touched" => "_form_type,_persistent_id,_touched,firstname,id,lastname",
    "firstname" => "UPDATED",
    "id" => "4",
    "lastname" => "UPDATED"
  }
}

I was initially configuring the forms manually, but then switched to auto.

Here are the main parts of my form component:

<.simple_form
  for={@form}
  id="profile-form"
  phx-target={@myself}
  phx-change="validate"
  phx-submit="save"
>
  <.inputs_for :let={user_form} field={@form[:user]}>
    <.input field={user_form[:firstname]} type="text" label="First name" />
    <.input field={user_form[:lastname]} type="text" label="Last name" />
  </.inputs_for>

  <.input field={@form[:contact_email]} type="text" label="Contact email" />
  <:actions>
    <.button phx-disable-with="Saving...">
      Save Profile
    </.button>
  </:actions>
</.simple_form>
def update(%{profile: profile, action: action} = assigns, socket) do
  form =
    case action do
      :new ->
        AshPhoenix.Form.for_create(Profile, :create, forms: [auto?: true])
        |> AshPhoenix.Form.add_form([:user])

      :edit ->
        AshPhoenix.Form.for_update(profile, :update, forms: [auto?: true])
    end

  {:ok,
    socket
    |> assign(assigns)
    |> assign(form: to_form(form))}
end
def handle_event("validate", %{"form" => profile_params}, socket) do
    {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, profile_params))}
  end
def handle_event("save", %{"form" => profile_params}, socket) do
  case AshPhoenix.Form.submit(socket.assigns.form, params: profile_params) do
    {:ok, profile} -> ...

As you can see it’s really straightforward, and I think I followed the docs by the book.

I’ve created a second minimal reproduction example integrated into Phoenix this time, so my complete form_component.ex is available here in case that’s useful: lib/ash_repro_web/live/profile_live/form_component.ex

:thinking: are you using the 3.0 release candidates?

Not at all, just the latest v2/v1 versions:

{:ash, "== 2.21.6"},
{:ash_phoenix, "== 1.3.4"},
{:ash_postgres, "== 1.5.23"},

Using your reproduction I was able to isolate the issue. It had actually been reported some time ago but was overlooked for a fix :frowning:. v2.21.7 of Ash has your fix.

EDIT: link to the issue for those curious: Cast values to the proper type and use `Ash.Type.equal?` · Issue #665 · ash-project/ash · GitHub

2 Likes

Wow, thank you so much Zach! I am glad I reached out, kudos for your reactivity on the forum and the fix :raised_hands: Good luck with 3.0!

1 Like