How should I manage inserting into related tables

709 Views Asked by At

I have an account table that links to an emails table, roughly as follows:

enter image description here

Currently, my changeset on accounts uses cast_assoc to pull in the email:

|> cast_assoc(:emails, required: true, with: &Email.changeset(&1, &2))

But this means I need to provide the data as follows:

%{
    username: "test",
    password: "secret",
    emails: %{email: "[email protected]"} //<- nested
}

I'm using GraphQL, and in order to support a "register" mutation of the form:

register(username:"test", password:"secret", email: "[email protected]")

I need to:

  1. Reformat my input in order to pass it into my changeset for ecto (nest it within emails)
  2. Flatten my changeset errors in order to return validation messages

Is there a way to refactor this, or to modify my changeset to un-nest the field? I'm new to elixir and ecto.

2

There are 2 best solutions below

3
On BEST ANSWER

Your question touches upon different points in the application, so I'll make an assumption that you're using Phoenix >= 1.3 as well as Absinthe. That way, we can talk about what your context and your resolvers might look like.

Handling incoming GraphQL requests involves going through two levels of abstraction before reaching the changeset functions in your domain modules: first, the resolver; and then, the context module. An important good practice is that your resolver should only call context functions. The idea is to leave the resolver uncoupled from the underlying domain modules where your Ecto schemas live.

You can then use the resolver to massage your input to make it fit whatever your context function expects. Assuming your context is named Accounts, your resolver might look a bit like this:

def register(_root, %{username: username, password: password, email: email}, _info) do
  args = %{username: username, password: password, emails: [%{email: email}]}

  case Accounts.create_account(args) do
    {:ok, %Account{} = account} ->
      {:ok, account}

    {:error, changeset} ->
      {:error, message: "Could not register account", details: error_details(changeset)}
  end
end

Which then calls this simple helper function that relies on traverse_errors/2 to return all validation messages:

defp error_details(changeset) do
  changeset
  |> Ecto.Changeset.traverse_errors(fn {msg, _} -> msg end)
end
3
On

I'm in a similar boat (using GraphQL), and I've elected to stay as far away from cast_assoc as possible. This is less because of the "create" scenario and more because of the "update" scenario.

Looking at the documentation for cast_assoc, you'll see it says...

  • If the parameter does not contain an ID, the parameter data will be passed to changeset/2 with a new struct and become an insert operation
  • If the parameter contains an ID and there is no associated child with such ID, the parameter data will be passed to changeset/2 with a new struct and become an insert operation
  • If the parameter contains an ID and there is an associated child with such ID, the parameter data will be passed to changeset/2 with the existing struct and become an update operation
  • If there is an associated child with an ID and its ID is not given as parameter, the :on_replace callback for that association will be invoked (see the “On replace” section on the module documentation)

Scenario 1 is your standard create, meaning your data needs to look similar to your nested input above. (it actually needs to be a list of maps for the email key.

Let's say a person adds a second email (you indicated this is a one-to-many above). If your input looks like:

%{
  id: 12345,
  username: "test",
  emails: [
    %{email: "[email protected]"}
  }
}

...this triggers both Scenario 1 (new param, no ID) and Scenario 4 (child with ID isn't given) effectively removing all prior emails. Which means your update params actually need to look like:

%{
  id: 12345,
  username: "test",
  emails: [
    %{id: 1, email: "[email protected]"},
    %{email: "[email protected]}
  ]
}

...which, to me, means queueing up a lot of extra data within requests. For something like emails -- of which a user is unlikely to have few -- the cost is low. For a more-abundantly-created association, a pain.

Rather than always putting cast_assoc into your User.changeset, one option would be creating a specific changeset for registration, that only uses cast once:

defmodule MyApp.UserRegistration do
  [...schema, regular changeset...]

  def registration_changeset(params) do
    %MyApp.User{}
    |> MyApp.Repo.preload(:emails)
    |> changeset(params)
    |> cast_assoc(:emails, required: true, with: &MyApp.Email.changeset(&1, &2))
  end
end

You'll still need to provide a nested emails field in your input, which is maybe a bummer, but at least then you're not polluting your normal User changeset with cast_assoc.

One last thought: Rather than have your client care about nesting, you could do that in your registration-specific resolver function?