Creating two records in one transaction

In my Phoenix app I have the following schemas:

defmodule TattooBackend.Accounts.Account do
  schema "accounts" do
    field :email, :string
    field :crypted_password, :string
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true

    has_one :studio, Studio

    timestamps()
  end
end

defmodule TattooBackend.Accounts.Studio do
  schema "studios" do
    field :name, :string

    belongs_to :account, Account

    timestamps()
  end
end

Now I’m trying to write code for inserting Account and Studio to the database in one transaction. I’m using Ecto.Multi for doing that. My code looks like this:

multi = Multi.new |>
Multi.insert(:account, Account.changeset(%Account{}, %{email: "test@email.com", password: "password", password_confirmation: "password"})) |>
Multi.run(:studio, fn %{account: account} ->
  studio_changeset = Studio.changeset(%Studio{}, %{name: "test", account_id: account.id})
  Repo.insert(studio_changeset)
end)

Repo.transaction(multi)

This code works perfectly fine, but I’m wondering if there is any place to make it better. I’m still pretty new to Elixir so I want to know if something can be done in better way. Is using Ecto.Multi the only one way of doing it? Maybe this can be done in another way?

I have not tried, but if you are interested in trying a different way you could dig through the code and see if there’s an easy way to manually control the BEGIN and COMMIT I suppose.

I’m wondering if there is any way of doing that using Ecto put_assoc or cast_assoc.

Mat,

It should work with cast_assoc. You put the studio as a child in the params for account. Like

%{
  email: "test@email.com", password: "password", password_confirmation: "password",
  studio: %{name: "test"}
}

And you have to add the cast_assoc into your Account.changeset function.

Then it is only one Multi.insert. I am not sure if you just do an Repo.insert if it still run as a transaction, but I would guess it will.

1 Like

Yes you can.

Use cast_assoc into changeset of your account to assign account_id for the new studio.

1 Like

@vlad.grb @marcuslankenau I’ve added to my Account changeset cast_assoc so it looks like this:

 defmodule TattooBackend.Accounts.Account do
  schema "accounts" do
    field :email, :string
    field :crypted_password, :string
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true

    has_one :studio, Studio

    timestamps()
  end
  def changeset(%Account{} = account, attributes \\ %{}) do
    account
    |> cast(attributes, [:email, :password, :password_confirmation])
    |> cast_assoc(:studio)
    |> validate_required([:email, :password, :password_confirmation])
  end
end

Now I’m trying to create Account with Studio like this:

changeset = Account.changeset(%Account{}, %{email: "chuk@o2.pl", password: "password", password_confirmation: "password", studio: %{name: "Test"}})
Repo.insert(changeset)

Unfortunately this returns following error:

{:error,
 #Ecto.Changeset<action: :insert,
  changes: %{email: "chuk@o2.pl", password: "password",
    password_confirmation: "password",
    studio: #Ecto.Changeset<action: :insert, changes: %{name: "Test"},
     errors: [account_id: {"can't be blank", [validation: :required]}],
     data: #TattooBackend.Accounts.Studio<>, valid?: false>}, errors: [],
  data: #TattooBackend.Accounts.Account<>, valid?: false>}

How can I fix that?

Can you pls add the Studio module code?

1 Like

I am now sure, but it seems like you missed foreign key or reference from the studio schema.

1 Like

@marcuslankenau @vlad.grb Here is Studio module code:

defmodule TattooBackend.Accounts.Studio do
  schema "studios" do
    field :name, :string

    belongs_to :account, Account

    timestamps()
  end

  def changeset(%Studio{} = studio, attributes \\ %{}) do
    studio
    |> cast(attributes, [:name, :account_id])
    |> validate_required([:name, :account_id])
    |> unique_constraint(:name)
    |> foreign_key_constraint(:account_id)
  end
end

I would remove account_id from the validate_required list and try agian. I am not sure if it makes sense to put an FK ti the required fields.

1 Like

@marcuslankenau After removing account_id from validate_required list everything works. But this lead to another problem. Lets say that I try to create Studio without Account in console:

studio_changeset = Studio.changeset(%Studio{}, %{name: “Test Studio”})
Repo.insert(studio_changeset)

I expect to see nice error message in changeset. What I see is postgrex error:

** (Postgrex.Error) ERROR 23502 (not_null_violation): null value in column "account_id" violates not-null constraint

    table: studios
    column: account_id

I assume in that case you have to load Account first and then using build_assoc create a new Studio object

1 Like

Hmm, that sounds like a nonsense. This is so trivial example. I want to receive error when I try to create Studio without account_id, that’s all. Why I need to load Account first? If Ecto fails on such basic examples it’s hard to imagine how it works on bigger apps…

I have the same feeling last few months. And it was multiplied when Phoenix 1.3 was announced and there is no any current template how to build your app like in Rails. But Phoenix is not Rails. Elixir is not Ruby.

The first thing is to understand that Ecto is not a MODEL pattern, it is not ACTIVE RECORD, it is a “wrapper” to easily create instance and persist. Ecto is an utility to create SQL queries and to simplify projection on db table from elixir. I agree with you. I find it a bit weird too. But the concept is to create dependent schemas using parent schema which is safe.

I bet that you put null:false for account_id instead of add account_id: references(:accounts) which will trigger foreign_key validation at db level instead of null validation.

1 Like

@vlad.grb: Yes I put null:false on references account because I need to have integrity on database level and be sure that there is no way to create Studio without Account:

  def change do
    create table(:studios) do
      add :name, :string, null: false
      add :account_id, references(:accounts, on_delete: :delete_all), null: false

      timestamps()
    end

    create unique_index(:studios, :name)
  end

You are right, it is quite a simple task. And I guess there is a trick to get this done properly, that @chrismccord or @josevalim will bring up when they read this thread. Until then I think there are workarounds like skipping the not null on account_id.

We are creating a bigger app using Phoenix/Ecto and I am quite happy with it. Sometimes it gets complicated in ways the we have to learn new stuff. :slight_smile: Coming from Rails I feel that there were issues as well and documentation was really bad there.

1 Like

I like Phoenix very much. The only hard time that I have is Ecto…