1
votes

I have a parent and a child component. I'd like to create the parent at the same time as the child because the parent can't exist without the child. Specifically I have a subscriptions which has_many services

If my child model has a required field being the foreign constraint, how do I create both models at the same time? I get an error in my changeset indicating that the parent.id cannot be blank.

I know I can do Repo.insert!(Subscription) followed by creating a Service changeset with subscription.id, but I was wondering if it is possible to create both at the same time?

My parent and child changesets are listed below:

Parent (Subscription)

def changeset(struct, params \\ %{}) do
# get the current time and add 30 days.
    {:ok, active_until} = DateTime.utc_now()
      |> DateTime.to_unix() |> Kernel.+(2592000) |> DateTime.from_unix()

    struct
    |> change(active_until: active_until)
    |> cast(params, [:active_until, :user_id])
    |> cast_assoc(:services)
    |> validate_required([:active_until])
end

Child (Service)

def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:start_time, :frequency, :subscription_id])
    |> validate_required([:subscription_id])
    |> foreign_key_constraint(:subscription_id)
end
3
What field in Subscription depends on Service here? And can you post the exact error message?Dogbert

3 Answers

1
votes

This is an egg-chicken problem: the unique ID is being assigned to the master record by the database engine. So, it’s impossible to execute this in one single transaction.

The only possibility would be to handle the ID key on master table yourself, via DB internal function to generate GUID (like UUID() in MySQL, or CREATE SEQUENCE in PostgreSQL.) In that case, one might call this function in advance and set the ID explicitly.

I would not recommend the latter approach, though.

1
votes

This is an old question but answering in case somebody else lands here like me.

def changeset(%Subscription{} = subscription, attrs) do
  subscription
  |> cast(attrs, [...])
  |> ...
  |> cast_assoc(:services, required: true)
  |> ...
end

def create_subscription(attrs \\ %{}) do
  %Subscription{}
  |> Subscription.changeset(attrs)
  |> Repo.insert()
end

This should do the job

1
votes

As Aleksei rightly pointed out, this is a chicken-and-egg problem based on the parent entity id not being available for Ecto associations at the time you're preparing the statements. In my opinion, what you're asking is possible only through the use of an Ecto.Multi-driven transaction. A transaction will ensure that even if you succeed inserting the parent entity, but one of the child entities fails validation checks, the whole transaction will be rolled back and no inconsistencies will arise.

Here's the general idea.

  • First, in your context module, define a new function for doing transactional inserts:
  def create_parent_with_children(attrs \\ %{}) do
    Ecto.Multi.new()
    # Insert the parent entity
    |> Ecto.Multi.insert(:parent_entity, Parent.changeset(%Parent{}, attrs))
    # then use the newly created  parent's id to insert all children
    |> Ecto.Multi.merge(fn %{parent_entity: par} ->
      attrs["children"]
      |> Enum.reduce(Ecto.Multi.new, fn child, multi ->
        # important: name each child transaction with a unique name
        child_multi_id = :"#{child["uniq_field1"]}_#{child["uniq_field2"]}"
        Ecto.Multi.insert(multi, child_multi_id, %Child{parent_id: par.id}
          |> Child.changeset(child))
      end)
    end)
    |> Repo.transaction()
  end
  • Then add a new POST/create handler to use the newly defined function in your parent entity's REST controller for nested parent/child structs:
def create(conn, %{"parent" => %{"children" => _} = parent_attrs}) do
    with {:ok, %{parent: parent}} <- Context.create_parent_with_children(parent_attrs) do
... (same body as create_parent/2)

make sure to add this before the existing create/2 handler, as its using a stricter match on the incoming JSON struct.

  • Last but not least, define an extra error handler in your FallbackController as the error struct returned from the method above is slightly different:
  def call(conn, {:error, failed_tran, %Ecto.Changeset{} = changeset, _parent}) do
    conn
    |> put_resp_header("x-failed-transaction", Atom.to_string(failed_tran))
    |> put_status(:unprocessable_entity)
    |> put_view(InterfixWeb.ChangesetView)
    |> render("error.json", changeset: changeset)
  end