How to create a product with many_to_many tags?

wintermeyer
2023-09-20

wintermeyer:

Setup:

defmodule App.Shop.Product do
  [...]
  relationships do
    many_to_many :tags, App.Shop.Tag do
      through App.Shop.ProductTag
      source_attribute_on_join_resource :product_id
      destination_attribute_on_join_resource :tag_id
    end
  end

  actions do
    defaults [:create, :read]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_name, get_by: [:name], action: :read
  end
end
defmodule App.Shop.Tag do
  [...]

  relationships do
    many_to_many :products, App.Shop.Product do
      through App.Shop.ProductTag
      source_attribute_on_join_resource :tag_id
      destination_attribute_on_join_resource :product_id
    end
  end

  actions do
    defaults [:create, :read]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_name, get_by: [:name], action: :read
  end
end
defmodule App.Shop.ProductTag do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  relationships do
    belongs_to :product, App.Shop.Product do
      primary_key? true
      allow_nil? false
    end

    belongs_to :tag, App.Shop.Tag do
      primary_key? true
      allow_nil? false
    end
  end
end

The iex preparation:

iex(1)> good_deal_tag = App.Shop.Tag.create!(%{name: "Good deal"})
iex(2)> yellow_tag = App.Shop.Tag.create!(%{name: "Yellow"})

Question: How can I create a new product which has the two tags good_deal_tag and yellow_tag ? Something like:

iex(3) App.Shop.Product.create!(%{name: "Banana", tags: [good_deal_tag, yellow_tag]})

zachdaniel:

Should the tags already exist?

zachdaniel:

Or should it create tags if they don’t exist yet?

zachdaniel:

If the former:

argument :tags, {:array, :map}

change manage_relationship(:tags, type: :append_and_remove}

zachdaniel:

that would delete/create join rows accordingly

wintermeyer:

Is the later possible too?

zachdaniel:

yep!

wintermeyer:

Where exactly do I have to put this code?

argument :tags, {:array, :map}
change manage_relationship(:tags, type: :append_and_remove}

zachdaniel:

That would go in the action

zachdaniel:

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

  change manage_relationship(:tags, type: :append_and_remove, on_no_match: :create}
end

wintermeyer:

  actions do
    defaults [:read, :update, :destroy]

    create :create do
      argument :tags, {:array, :map}
      change manage_relationship(:tags, type: :append_and_remove, on_no_match: :create)
    end
  end

This raises an error:

$ iex -S mix
Compiling 2 files (.ex)
** (EXIT from #PID<0.98.0>) an exception was raised:
    ** (Spark.Error.DslError) [App.Shop.Product]
 actions -> create -> create -> change -> manage_relationship -> tags:
  The following error was raised when validating options provided to manage_relationship.

** (RuntimeError) Required primary create action for App.Shop.ProductTag.
[...]

zachdaniel:

Yes, managing relationships uses actions on the target resources

zachdaniel:

and by default, it uses the primary actions

zachdaniel:

there aren’t any actions on ProductTag a far as I can tell

wintermeyer:

Here’s the current code.

defmodule App.Shop.Tag do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
  end

  relationships do
    many_to_many :products, App.Shop.Product do
      through App.Shop.ProductTag
      source_attribute_on_join_resource :tag_id
      destination_attribute_on_join_resource :product_id
    end
  end

  actions do
    defaults [:read, :update, :destroy]

    create :create do
      primary? true
      argument :products, {:array, :map}
      change manage_relationship(:products, type: :append_and_remove, on_no_match: :create)
    end
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end
defmodule App.Shop.Product do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  relationships do
    many_to_many :tags, App.Shop.Tag do
      through App.Shop.ProductTag
      source_attribute_on_join_resource :product_id
      destination_attribute_on_join_resource :tag_id
    end
  end

  actions do
    defaults [:read, :update, :destroy]

    create :create do
      primary? true
      argument :tags, {:array, :map}
      change manage_relationship(:tags, type: :append_and_remove, on_no_match: :create)
    end
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end

wintermeyer:

defmodule App.Shop.ProductTag do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  relationships do
    belongs_to :product, App.Shop.Product do
      primary_key? true
      allow_nil? false
    end

    belongs_to :tag, App.Shop.Tag do
      primary_key? true
      allow_nil? false
    end
  end
end

That results in this error:

$ iex -S mix
Compiling 2 files (.ex)
** (EXIT from #PID<0.98.0>) an exception was raised:
    ** (Spark.Error.DslError) [App.Shop.Product]
 actions -> create -> create -> change -> manage_relationship -> tags:
  The following error was raised when validating options provided to manage_relationship.

** (RuntimeError) Required primary create action for App.Shop.ProductTag.
    (ash 2.14.17) lib/ash/resource/info.ex:493: Ash.Resource.Info.primary_action!/2
...

zachdaniel:

defmodule App.Shop.ProductTag do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  # actions required for the join resource

  relationships do
    belongs_to :product, App.Shop.Product do
      primary_key? true
      allow_nil? false
    end

    belongs_to :tag, App.Shop.Tag do
      primary_key? true
      allow_nil? false
    end
  end
end

wintermeyer:

Thanks! Solution for the archive:

defmodule App.Shop.ProductTag do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  relationships do
    belongs_to :product, App.Shop.Product do
      primary_key? true
      allow_nil? false
    end

    belongs_to :tag, App.Shop.Tag do
      primary_key? true
      allow_nil? false
    end
  end
end
iex(1)> good_deal_tag = App.Shop.Tag.create!(%{name: "Good deal"})
iex(2)> yellow_tag = App.Shop.Tag.create!(%{name: "Yellow"})
iex(3)> App.Shop.Product.create!(%{name: "Banana", tags: [good_deal_tag, yellow_tag]})
iex(4)> App.Shop.Product.by_name!("Banana", load: [:tags])
#App.Shop.Product<
  tags: [
    #App.Shop.Tag<
      products: #Ash.NotLoaded<:relationship>,
      products_join_assoc: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "82b7e8af-69b9-4f35-b32a-0b6b2bed1d15",
      name: "Good deal",
      aggregates: %{},
      calculations: %{},
      ...
    >,
    #App.Shop.Tag<
      products: #Ash.NotLoaded<:relationship>,
      products_join_assoc: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "d04aa5ef-195e-4dd8-9c5a-5c73e6f44afe",
      name: "Yellow",
      aggregates: %{},
      calculations: %{},
      ...
    >
  ],
  ...
>