Trying to figure out how to create or update multiple many_to_many items and associations when none of the items don't exist in the db yet

Hey! My apologies if this question is a bit confusingly worded, I’m a beginner to phoenix and am picking up the lingo along the way.

I’m building a phoenix api project and I have been having a bit of trouble wrapping my head around how to implement building associations between 2 schemas for when you might not have previously inserted them in the past – but still have the flexibility available to me to add a child object into its own schema.

More concretely, I have the concept of a seller and a product. I’d like to be able to insert products on their own into my db, but I also want to be able to create a new seller with a new product and insert it into the db in one transaction. If the product actually happened to already exist when inserting a new merchant, I’d like to have that product then be associated with the merchant.

So, from a client, I was hoping that my inbound json payload would potentially be represented by the following structure.

Note, at this point, neither the seller or the product exists in the db yet:

{
	"seller": {
		"name": "seller1",
		"description": "the first seller",
		"product": {
			"name": "product1"
		}
	}
}

After this point, I would like to make it so I could proceed to do something where I can create a new seller but link it up with a potentially existing product that already exists in my products table:

{
	"seller": {
		"name": "seller2",
		"description": "the second seller",
		"product": {
			"name": "product1"
		}
	}
}

I’m actually struggling with the first step, because I’m not entirely sure how to insert both of those new entities at the same time and build out the association upon insert. Do I need to insert them one at a time? Or is it at all possible for me to do it all in one transaction? Or do I need to somehow access the child product json object, insert that into my products db, then after it has been inserted, proceed to insert the seller. After the seller is inserted, proceed to build the association? I feel like there should be a way to insert them both at the same time?

What I have so far:

My Seller Schema:

defmodule MyApp.Sellers.Seller do
  use Ecto.Schema
  import Ecto.Changeset
  @primary_key {:id, :binary_id, autogenerate: true}
  schema "sellers" do
    field :name, :string
    field :description, :string

    many_to_many :products, MyApp.Offerings.Product,
      join_through: "sellers_products",
      on_replace: :delete

    timestamps()
  end

  def changeset(seller, attrs) do
    seller
    |> cast(attrs, [
      :name,
      :description,
    ])
    |> cast_assoc(:products) #How do I create both a seller and a product at the same time from the nested json?
    |> validate_required([:name, :description])
  end
end

My Product Schema:

defmodule MyApp.Offerings.Product do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  schema "products" do
    field :name, :string

    many_to_many :sellers, MyApp.Sellers.Seller,
      join_through: "sellers_products",
      on_replace: :delete

    timestamps()
  end

  def changeset(product, attrs) do
    product
    |> cast(attrs, [
      :name
    ])
    |> validate_required([
      :name
    ])
  end
end

My associated migrations:

defmodule MyApp.Repo.Migrations.CreateProducts do
  use Ecto.Migration

  def change do
    create table(:products, primary_key: false) do
      add :id, :uuid, primary_key: true
      add :name, :string, null: false
      timestamps()
    end

    create unique_index(:products, [:name]) #Will help guarantee uniqueness
  end
end
defmodule MyApp.Repo.Migrations.CreateSellers do
  use Ecto.Migration

  def change do
    create table(:sellers, primary_key: false) do
      add :id, :uuid, primary_key: true
      add :name, :string, null: false
      add :description, :text, null: false

      timestamps()
    end
  end
end

And the many_to_many table creation:

defmodule MyApp.Repo.Migrations.AssociateProductsToSellers do
  use Ecto.Migration

  def change do
    create table(:sellers_products, primary_key: false) do
      add :seller_id, references(:sellers, type: :uuid), primary_key: true
      add :product_id, references(:products, type: :uuid), primary_key: true
      timestamps()
    end

    create index(:sellers_products, [:seller_id])
    create index(:sellers_products, [:product_id])

    create unique_index(:sellers_products, [:seller_id, :product_id])
  end
end

And finally, my Sellers Context create function:

  def create_seller(attrs \\ %{}) do #Should I be inserting the product here first, then inserting a seller, then associating them??
    |> Seller.changeset(attrs)
    |> Repo.insert()
  end

This is totally doable, please have a look at the many_to_many guide.

Note that the JSON would probably have to change to this:

{
	"seller": {
		"name": "seller1",
		"description": "the first seller",
		"product": [{
			"name": "product1"
		}]
	}
}

Hello,
Here’s a playground project where I’m setting a project and its associations upon creation :

schema "projects" do
    field :date_end, :date
    field :date_start, :date
    field :description, :string
    field :display_date, :string
    field :draft, :boolean, default: false
    field :place, :string
    field :price, :integer
    field :text, :string
    field :title, :string
    field :url, :string
    many_to_many :tags, Docs.Contents.Tag, join_through: "projects_tags"
    many_to_many :images, Docs.Contents.Photo, join_through: "projects_photos"
    has_many :translations, Docs.Contents.Translation, foreign_key: :target_id, where: [table: "projects"]
    belongs_to :commanditaire, Docs.Contents.Commanditaire
    belongs_to :main_image, Docs.Contents.Photo
    belongs_to :category, Docs.Contents.Category
    has_many :metadata, Docs.Contents.Metadata
    many_to_many :blogs, Docs.Contents.Blog, join_through: "blogs_projects"
    timestamps()
  end

def changeset(
        project,
        %{
          "tags" => t,
          "commanditaire" => co,
          "category" => ca,
          "metadata" => m,
          "images" => i,
          "main_image" => mi,
          "translations" => tr
        } = attrs
      ) do
    tags = Docs.Contents.DataUtilitites.get_or_create_tags(t)
    commanditaire = Docs.Contents.DataUtilitites.get_or_create_commanditaire(co)
    category = Docs.Contents.DataUtilitites.get_or_create_category(ca)
    metadata = to_metadata(m)
    translations = Docs.Contents.DataUtilitites.create_or_update_translations(tr, "projects", project)
    images = to_images(i)
    main_image = to_main_image(mi)
    project
    |> cast(attrs, [
      :title,
      :draft,
      :url,
      :description,
      :date_start,
      :date_end,
      :display_date,
      :text,
      :price,
      :place,
    ])
    |> put_assoc(:tags, tags)
    |> put_assoc(:main_image, main_image)
    |> put_assoc(:category, category)
    |> put_assoc(:commanditaire, commanditaire)
    |> put_assoc(:images, images)
    |> put_assoc(:metadata, metadata)
    |> put_assoc(:translations, translations)
    |> validate_required([
      :title,
      :draft,
      :url,
      :description,
      :date_start,
      :date_end,
      :display_date,
      :price,
      :place,
      :text
    ])
  end

And the assoc fetch-or-create part :

defmodule Docs.Contents.DataUtilitites do
  @moduledoc """
  Contains helpers that are generic enough to serve
  all of our CRUD models.
  """

  def get_or_create_category(category) do
    if is_binary(category) do
      {:ok, c} = Docs.Contents.create_category(%{"name" => category, "slug" => Slug.slugify(category)})
      c
    else
      Docs.Contents.get_category!(Map.get(category, "id"))
    end
  end

  def get_or_create_tags(tags) do
    {existing_tags, new_tags} = tags
      |> Enum.reduce({[],[]}, fn tag, {ex, new} ->
          if is_binary(tag) do
            {:ok, new_tag} = Docs.Contents.create_tag(%{"name" => tag, "slug" => Slug.slugify(tag)})
            {ex, [new_tag | new]}
          else
            db_tag = Docs.Contents.get_tag!(Map.get(tag, "id"))
            {[db_tag | ex], new}
          end
      end)
    existing_tags ++ new_tags
  end

  def create_or_update_translations(translations, table, obj \\ nil) do
    Enum.map(translations, fn trans ->
      if Map.has_key?(trans, "id") do
        db_trans = Docs.Contents.get_translation!(Map.get(trans, "id"))
        {:ok, db_trans} = db_trans |> Docs.Contents.update_translation(%{
          "content" => Map.get(trans, "content")
        })
        db_trans
      else
        {:ok, db_trans} = Docs.Contents.create_translation(%{
          "content" => Map.get(trans, "content"),
          "table" => table,
          "lang" => Map.get(trans, "lang"),
          "target_id" => obj.id
        })
        db_trans
      end
    end)
  end

  def get_or_create_commanditaire(commanditaire) do
    if is_binary(commanditaire) do
      {:ok, c} = Docs.Contents.create_commanditaire(%{"name" => commanditaire, "slug" => Slug.slugify(commanditaire)})
      c
    else
      Docs.Contents.get_commanditaire!(Map.get(commanditaire, "id"))
    end
  end

Some parts are redundant with what Ecto is capable of doing for you, or not-too-clean. It’s a test. You can find the whole thing here if you’d like : https://github.com/Lucassifoni/documents-next/tree/master/lib/docs/contents

Hoping it would be useful to you. Don’t pay too much attention to translation/form generation, it’s tangent to your question…

Awesome! Though would there be a possible way to guarantee that there’s only one product? I know at the moment I’ve built it to be a many-to-many association but could what I want actually fall into the has_one/belongs_to category? Or really I guess what I would want is a singular product can “belong_to_many” sellers…