Nested relationship not updating properly

I have some resources related to each other and I am trying to update them but am getting a "would leave records behind" error.

Resources

The resources and relationships are as follows.

Reactant

defmodule Flame.App.Reactant do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer

Actions

  actions do
    defaults [:create, :read, :update, :destroy]
    update :redefine do
      argument :sources, {:array, :map}

      argument :formulas, {:array, :map}

      change manage_relationship(:sources, :sources,
               on_lookup: :relate,
               on_match: :ignore,
               on_no_match: :create,
               on_missing: :unrelate,
               use_identities: [:unique_description]
             )

      change manage_relationship(:formulas, :formulas,
               error_path: :formulas,
               on_lookup: :ignore,
               on_no_match: :create,
               on_match: :update,
               on_missing: :destroy,
               use_identities: [:_primary_key]
             )
    end
  end

Relationships

  relationships do
    has_many :formulas, Flame.App.Formula do
      destination_attribute :owner_id
    end

    many_to_many :sources, Flame.App.Source do
      through Flame.App.ReactantSource
      source_attribute_on_join_resource :reactant_id
      destination_attribute_on_join_resource :source_id
    end
  end
end

Formula

defmodule Flame.App.Formula do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer

Actions

  actions do
    defaults([:read])

    create :create do
      argument :formula, {:array, :map}

      primary? true

      change manage_relationship(:formula, :reactants_join_assoc,
               on_lookup: :ignore,
               on_no_match: :create,
               on_match: :update,
               on_missing: :destroy
             )
    end

    update :update do
      argument :formula, {:array, :map}

      primary? true

      change manage_relationship(:formula, :reactants_join_assoc,
               on_lookup: :ignore,
               on_no_match: :create,
               on_match: :update,
               on_missing: :destroy
             )
    end

    destroy :destroy do
      argument :formula, {:array, :map}

      primary? true

      change manage_relationship(:formula, :reactants_join_assoc,
               on_lookup: :ignore,
               on_no_match: :ignore,
               on_match: :destroy,
               on_missing: :destroy
             )
    end
  end

Relationships

  relationships do
    belongs_to :owner, Flame.App.Reactant

    many_to_many :reactants, Flame.App.Reactant do
      through Flame.App.FormulaReactant
      source_attribute_on_join_resource :formula_id
      destination_attribute_on_join_resource :reactant_id
    end
  end
end

FormulaReactant

defmodule Flame.App.FormulaReactant do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer

Actions

  actions do
    defaults [:read, :destroy]

    create :create do
      argument :reactant, :map do
        allow_nil? false
      end

      argument :unit, :map do
        allow_nil? false
      end

      primary? true

      change manage_relationship(:reactant, :reactant,
               on_lookup: :relate,
               on_no_match: :error,
               on_match: :ignore,
               on_missing: :unrelate,
               use_identities: [:unique_identity]
             )

      change manage_relationship(:unit, :unit,
               on_lookup: :relate,
               on_no_match: :error,
               on_match: :ignore,
               on_missing: :unrelate,
               use_identities: [:unique_alias]
             )
    end

    update :update do
      argument :reactant, :map do
        allow_nil? false
      end

      validate {Flame.App.Validations.ReactantExists, argument: :reactant}

      primary? true

      argument :unit, :map do
        allow_nil? false
      end

      change manage_relationship(:reactant, :reactant,
               on_lookup: :relate,
               on_no_match: :error,
               on_match: :ignore,
               on_missing: :unrelate,
               use_identities: [:unique_identity],
               error_path: :reactant
             )

      change manage_relationship(:unit, :unit,
               on_lookup: :relate,
               on_no_match: :error,
               on_match: :ignore,
               on_missing: :unrelate,
               use_identities: [:unique_alias],
               error_path: :unit
             )
    end
  end

Relationships

  relationships do
    belongs_to :unit, Flame.App.Unit, allow_nil?: false
    belongs_to :formula, Flame.App.Formula, primary_key?: true, allow_nil?: false
    belongs_to :reactant, Flame.App.Reactant, primary_key?: true, allow_nil?: false
  end
end

With these resources if I get a record and try to update it as follows.

Update Attempt

    record
    |> Ash.Changeset.for_update(:redefine, %{formulas: []})
    |> Flame.App.update()

I am attempting to remove all owned Flame.App.Formula but I get the following error.

Error

{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.InvalidAttribute{
       field: :id,
       message: "would leave records behind",
       private_vars: [
         constraint: :foreign,
         constraint_name: "formula_reactant_formula_id_fkey"
       ],
       value: nil,
       changeset: nil,
       query: nil,
       error_context: [],
       vars: [],
       path: [],
       stacktrace: #Stacktrace<>,
       class: :invalid
     }
   ],
   stacktraces?: true,
   changeset: #Ash.Changeset<
     api: Flame.App,
     action_type: :destroy,
     action: :destroy,
     attributes: %{},
     relationships: %{},
     arguments: %{},
     errors: [
       %Ash.Error.Changes.InvalidAttribute{
         field: :id,
         message: "would leave records behind",
         private_vars: [
           constraint: :foreign,
           constraint_name: "formula_reactant_formula_id_fkey"
         ],
         value: nil,
         changeset: nil,
         query: nil,
         error_context: [],
         vars: [],
         path: [],
         stacktrace: #Stacktrace<>,
         class: :invalid
       }
     ],
     data: #Flame.App.Formula<
       reactants: #Ash.NotLoaded<:relationship>,
       owner: #Ash.NotLoaded<:relationship>,
       reactants_join_assoc: #Ash.NotLoaded<:relationship>,
       __meta__: #Ecto.Schema.Metadata<:loaded, "formulas">,
       id: "481f3e87-300f-4584-bc85-2839f0a9b7c2",
       created_at: ~U[2023-10-18 06:09:08.155000Z],
       updated_at: ~U[2023-10-18 06:09:08.155000Z],
       owner_id: "79cc0070-e6aa-4fbe-b741-1db5a8149cdc",
       aggregates: %{},
       calculations: %{},
       ...
     >,
     context: %{
       accessing_from: %{name: :formulas, source: Flame.App.Reactant},
       actor: nil,
       authorize?: false
     },
     valid?: false
   >,
   query: nil,
   error_context: [nil],
   vars: [],
   path: [],
   stacktrace: #Stacktrace<>,
   class: :invalid
 }}

I’m not sure how to be able to unset the Flame.App.Formula on a Flame.App.Reactant.

Notes

  • I did not include the full resource specs since I thought they were unrequired
  • I am using the :'relationship'_join_assoc

The implication with the “would leave records behind” error is that you have a belongs_to relationship that uses a foreign key in the database that would be invalid if you deleted the thing you’re trying to delete. If you want to do this, you typically need to configure the references section of whichever entity has the belongs_to relationships. In your case, this is probably the join rows. I think it’s possible that this could be solved in ash core, as it should probably know to destroy the join row as well. Regardless, you could solve this with references I believe.

postgres do
  ...
  references do
    reference :belongs_to_relationship_foo, on_delete: :delete
    reference :belongs_to_relationship_bar, on_delete: :delete
  end
end

Keep in mind this is implemented in the database so you’ll need to generate migrations and run them for this to work.

The following change in Flame.App.FormulaReactant.

Before
  postgres do
    table "formula_reactant"
    repo Flame.Repo
  end
After
  postgres do
    table "formula_reactant"
    repo Flame.Repo

    references do
      reference :formula, on_delete: :delete, on_update: :update
      reference :reactant, on_delete: :delete, on_update: :update
    end
  end

This means that if I remove the parent resource, Flame.App.Formula is this case, the matching records in the join association resource, Flame.App.FormulaReactant here, are deleted|destroyed|removed. This is what I intended.
Now if I try to update the join association resource, Flame.App.FormulaReactant, trying to change one of its attributes or relationships I get a errors: [formula_id: {"has already been taken", []}] error. Flame.App.FormulaReactant has the following attributes and relationships.

Flame.App.FormulaReactant
Attributes
  attributes do
    attribute :quantity, :decimal do
      allow_nil? false
      constraints min: 0.0
    end
  end
Relationships
  relationships do
    belongs_to :unit, Flame.App.Unit, allow_nil?: false
    belongs_to :formula, Flame.App.Formula, primary_key?: true, allow_nil?: false
    belongs_to :reactant, Flame.App.Reactant, primary_key?: true, allow_nil?: false
  end

How can I be able to update the join resource,Flame.App.FormulaReactant, through the parent resource,Flame.App.Formula?

I think I’d need to see how you’re attempting to do the update. You can update related data using manage_relationship, which has variations in the options for updating fields on the join relationship. Alternatively, you can use manage_relationship with the underlying join relationship directly. Additionally, you can write actions that use after_action and before_action hooks to directly modify the related entities.

So a Flame.App.Formula belongs to a single Flame.App.Reactant and has a many to many relationship through the join resource Flame.App.FormulaReactant as visible below.

defmodule Flame.App.Formula do
  ...
  relationships do
    belongs_to :owner, Flame.App.Reactant

    many_to_many :reactants, Flame.App.Reactant do
      through Flame.App.FormulaReactant
      source_attribute_on_join_resource :formula_id
      destination_attribute_on_join_resource :reactant_id
    end
  end
end

The join resource below Flame.App.FormulaReactant has a :quantity attribute and three belongs to relationships. Two of the relationships are for the join between Flame.App.Formula and Flame.App.Reactant, these are both primary keys. The third belongs to is for another resourec Flame.App.Unit that is set for each join between a Flame.App.Formula and a Flame.App.Reactant.
The primary update action for Flame.App.FormulaReactant accepts two arguments :reactant and :unit to manage the relationships to Flame.App.Reactant and Flame.App.Unit respectively. Both are setup to only relate records if they exist and unrelate if the record is removed. They use custom identities to match the argument to existing records.

defmodule Flame.App.FormulaReactant do
  ...
  attributes do
    attribute :quantity, :decimal do
      allow_nil? false
      constraints min: 0.0
    end
  end
  
  actions do
    ...
        update :update do
      argument :reactant, :map do
        allow_nil? false
      end

      validate {Flame.App.Validations.ReactantExists, argument: :reactant}

      primary? true

      argument :unit, :map do
        allow_nil? false
      end

      change manage_relationship(:reactant, :reactant,
               on_lookup: :relate,
               on_no_match: :error,
               on_match: :ignore,
               on_missing: :unrelate,
               use_identities: [:unique_identity],
               error_path: :reactant
             )

      change manage_relationship(:unit, :unit,
               on_lookup: :relate,
               on_no_match: :error,
               on_match: :ignore,
               on_missing: :unrelate,
               use_identities: [:unique_alias],
               error_path: :unit
             )
    end
  end

  relationships do
    belongs_to :unit, Flame.App.Unit, allow_nil?: false
    belongs_to :formula, Flame.App.Formula, primary_key?: true, allow_nil?: false
    belongs_to :reactant, Flame.App.Reactant, primary_key?: true, allow_nil?: false
  end
end

Now, if I have the following Flame.App.Formula with relationships loaded. This record has only one row in Flame.App.FormulaReactant.

#Flame.App.Formula<
  reactants: #Ash.NotLoaded<:relationship>,
  owner: #Ash.NotLoaded<:relationship>,
  reactants_join_assoc: [
    #Flame.App.FormulaReactant<
      reactant: #Flame.App.Reactant<
        sources: #Ash.NotLoaded<:relationship>,
        formulas: #Ash.NotLoaded<:relationship>,
        sources_join_assoc: #Ash.NotLoaded<:relationship>,
        __meta__: #Ecto.Schema.Metadata<:loaded, "reactants">,
        id: "e67a1571-2cd0-46a7-a29b-66aa3b7685ef",
        identity: "fried",
        spec: [
          #Flame.App.Embed.Pair<
            __meta__: #Ecto.Schema.Metadata<:built, "">,
            autogenerated_id: "789d366a-fedf-4a54-ad39-d1cc292e4ade",
            key: "red",
            value: "white",
            aggregates: %{},
            calculations: %{},
            ...
          >
        ],
        created_at: ~U[2023-09-22 04:31:28.907000Z],
        updated_at: ~U[2023-09-23 04:22:51.470000Z],
        aggregates: %{},
        calculations: %{},
        ...
      >,
      formula: #Ash.NotLoaded<:relationship>,
      unit: #Flame.App.Unit<
        formula_reactants: #Ash.NotLoaded<:relationship>,
        __meta__: #Ecto.Schema.Metadata<:loaded, "units">,
        id: "704a2ab2-01cb-436d-b01a-82af9b4df65c",
        alias: "mm",
        type: :meter,
        scale: Decimal.new("0.001"),
        created_at: ~U[2023-10-17 09:57:50.348000Z],
        updated_at: ~U[2023-10-17 09:57:50.348000Z],
        aggregates: %{},
        calculations: %{},
        ...
      >,
      __meta__: #Ecto.Schema.Metadata<:loaded, "formula_reactant">,
      quantity: Decimal.new("32.5"),
      unit_id: "704a2ab2-01cb-436d-b01a-82af9b4df65c",
      formula_id: "1e52286e-d4dc-4acf-967d-ae47f8c3d13c",
      reactant_id: "e67a1571-2cd0-46a7-a29b-66aa3b7685ef",
      aggregates: %{},
      calculations: %{},
      ...
    >
  ],
  __meta__: #Ecto.Schema.Metadata<:loaded, "formulas">,
  id: "1e52286e-d4dc-4acf-967d-ae47f8c3d13c",
  created_at: ~U[2023-10-21 04:11:49.335000Z],
  updated_at: ~U[2023-10-21 04:11:49.335000Z],
  owner_id: "79cc0070-e6aa-4fbe-b741-1db5a8149cdc",
  aggregates: %{},
  calculations: %{},
  ...
>

I then try to update the join relationship :reactants_join_assoc with the following changeset. The arguments in the array have just enough data to match the same records for the Flame.App.Reactant and Flame.App.Unit relationships. The only data being changed is the attribute :quantity for the same row. So the existing join row should only be updated with a new value for :quantity.

formula
|> Ash.Changeset.new()
|> Ash.Changeset.manage_relationship(
:reactants_join_assoc,
[
  %{reactant: %{identity: "fried"}, unit: %{alias: "mm"}, quantity: 30.0}
],
on_lookup: :ignore,
on_no_match: :create,
on_match: :update,
on_missing: :destroy
)
|> Flame.App.update()

Instead I get the following error.

{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.InvalidAttribute{
       field: :formula_id,
       message: "has already been taken",
       private_vars: [
         constraint: "formula_reactant_pkey",
         constraint_type: :unique
       ],
       value: nil,
       changeset: nil,
       query: nil,
       error_context: [],
       vars: [],
       path: [],
       stacktrace: #Stacktrace<>,
       class: :invalid
     }
   ],
   stacktraces?: true,
   changeset: #Ash.Changeset<
     api: Flame.App,
     action_type: :create,
     action: :create,
     attributes: %{
       formula_id: "1e52286e-d4dc-4acf-967d-ae47f8c3d13c",
       quantity: Decimal.new("30.0"),
       reactant_id: "e67a1571-2cd0-46a7-a29b-66aa3b7685ef",
       unit_id: "704a2ab2-01cb-436d-b01a-82af9b4df65c"
     },
     relationships: %{
       reactant: [
         {[%{identity: "fried"}],
          [
            handled?: true,
            ignore?: false,
            eager_validate_with: false,
            authorize?: true,
            meta: [inputs_was_list?: false, id: :reactant],
            on_lookup: :relate,
            on_no_match: :error,
            on_match: :ignore,
            on_missing: :unrelate,
            use_identities: [:unique_identity]
          ]}
       ],
       unit: [
         {[%{alias: "mm"}],
          [
            handled?: true,
            ignore?: false,
            eager_validate_with: false,
            authorize?: true,
            meta: [inputs_was_list?: false, id: :unit],
            on_lookup: :relate,
            on_no_match: :error,
            on_match: :ignore,
            on_missing: :unrelate,
            use_identities: [:unique_alias]
          ]}
       ]
     },
     arguments: %{reactant: %{identity: "fried"}, unit: %{alias: "mm"}},
     errors: [
       %Ash.Error.Changes.InvalidAttribute{
         field: :formula_id,
         message: "has already been taken",
         private_vars: [
           constraint: "formula_reactant_pkey",
           constraint_type: :unique
         ],
         value: nil,
         changeset: nil,
         query: nil,
         error_context: [],
         vars: [],
         path: [],
         stacktrace: #Stacktrace<>,
         class: :invalid
       }
     ],
     data: #Flame.App.FormulaReactant<
       reactant: #Ash.NotLoaded<:relationship>,
       formula: #Ash.NotLoaded<:relationship>,
       unit: #Ash.NotLoaded<:relationship>,
       __meta__: #Ecto.Schema.Metadata<:built, "formula_reactant">,
       quantity: nil,
       unit_id: nil,
       formula_id: nil,
       reactant_id: nil,
       aggregates: %{},
       calculations: %{},
       ...
     >,
     context: %{
       accessing_from: %{name: :reactants_join_assoc, source: Flame.App.Formula},
       actor: nil,
       authorize?: false
     },
     valid?: false
   >,
   query: nil,
   error_context: [nil],
   vars: [],
   path: [],
   stacktrace: #Stacktrace<>,
   class: :invalid
 }}

Based on this input: %{reactant: %{identity: "fried"}, unit: %{alias: "mm"}, quantity: 30.0}, how should it know which join row is being updated. You’d need to include the reactant_id, since it’s using the primary key to detect conflicts.

Ah, I think I see. It won’t use nested fields to detect matches, i.e reactant.identity.

Yeah, that seems to be the case, I put the full record for reactant, visible below, in %{reactant: fried_reactant_record, unit: %{alias: "mm"}, quantity: 30.0} and still got the field: :formula_id, message: "has already been taken" error.

#Flame.App.Reactant<
  sources: #Ash.NotLoaded<:relationship>,
  formulas: #Ash.NotLoaded<:relationship>,
  sources_join_assoc: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded, "reactants">,
  id: "e67a1571-2cd0-46a7-a29b-66aa3b7685ef",
  identity: "fried",
  spec: [
    #Flame.App.Embed.Pair<
      __meta__: #Ecto.Schema.Metadata<:built, "">,
      autogenerated_id: "789d366a-fedf-4a54-ad39-d1cc292e4ade",
      key: "red",
      value: "white",
      aggregates: %{},
      calculations: %{},
      ...
    >
  ],
  created_at: ~U[2023-09-22 04:31:28.907000Z],
  updated_at: ~U[2023-09-23 04:22:51.470000Z],
  aggregates: %{},
  calculations: %{},
  ...
>

Should I set the join ids to be writable and set them directly?

I changed the input the argument to %{reactant: %{identity: "fried"}, reactant_id: fired_reactant_record.id, unit: %{alias: "mm"}, quantity: 30.0}. As you can see I am specifying the :reactant_id join id explicitly now. It succeeded.
I’m guessing I could use before_action to add the :reactant_id on update. Is this the ideal solution?

I changed the update :update action in Flame.App.Formula as follows.

Before

After

    update :update do
      argument :reactant_quantity, {:array, :map}

      primary? true

      change before_action(fn changeset ->
               with reactant_quantity when is_list(reactant_quantity) <-
                      Ash.Changeset.get_argument(changeset, :reactant_quantity) do
                 joins =
                   for reactant_join <- reactant_quantity,
                       reactant_param =
                         Map.get(reactant_join, "reactant") ||
                           Map.get(reactant_join, :reactant),
                       identity_param =
                         Map.get(reactant_param, "identity") || Map.get(reactant_param, :identity),
                       {:ok, [reactant]} =
                         Flame.App.Reactant
                         |> Ash.Query.filter(identity == ^identity_param)
                         |> Flame.App.read() do
                     reactant_join |> Map.put(:reactant_id, reactant.id)
                   end

                 changeset
                 |> Ash.Changeset.manage_relationship(
                   :reactants_join_assoc,
                   joins,
                   on_lookup: :ignore,
                   on_no_match: :create,
                   on_match: :update,
                   on_missing: :destroy
                 )
               else
                 _ -> changeset
               end
             end)
    end

Instead of using manage_relationship directly now it passes a callback to before_action. The callback, visible above, gets the argument for the join records, which would be [%{reactant: %{identity: "fried"}, unit: %{alias: "mm"}, quantity: 29.0}], loops over them extracting the identity field and using that to get the matching Flame.App.Reactant record. It constructs a new list of join record with the :reactant_id field included. The result would be something like [%{reactant: %{identity: "fried"}, unit: %{alias: "mm"}, quantity: 29.0, reactant_id: "some_uuid"}].
This list is then used with a call to manage_relationship resulting in a new changeset which is returned from the callback.
With this my updates are now going through without errors. @zachdaniel your diagnosis, below, was spot on. I was finding it difficult to understand what the error was by myself.
Cheers! :smiley:

1 Like