4

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: "test@123.com"} //<- nested
}

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

register(username:"test", password:"secret", email: "test@test.com")

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.

Marcelo De Polli
  • 28,123
  • 4
  • 37
  • 47
cnorris
  • 548
  • 4
  • 12

2 Answers2

4

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
Marcelo De Polli
  • 28,123
  • 4
  • 37
  • 47
  • Thanks for the response! This is close to what I'm doing now, but I'd also like to get back the validation errors. `traverse_errors/2` still returns the errors in a _series of nested maps_, so I end up with `emails: {email: "has invalid format"}`. I'm trying to return errors as {field, message} pairs, so I end up returning a field of "emails.email" (generically flattening the changeset) unless I also modify the changeset in the `register` resolver (which I'm having trouble doing as i'm new to elixir). So massaging the input works great, but massaging the output not so much. – cnorris May 31 '18 at 21:04
  • Another approach you could take is creating an `embedded_schema` for registration, and it's own changeset, such that the changeset errors look "flat" when returned to the client. Once everything is `valid?: true` you can then split the data out into its different, database-backed schemas – Lanny Bose Jun 01 '18 at 00:50
  • @cnorris It seems that issue can be solved by adjusting the logic in `error_details/1`. Do you agree? – Marcelo De Polli Jun 04 '18 at 14:15
1

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: "test_2@123.com"}
  }
}

...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: "test@123.com"},
    %{email: "test_2@123.com}
  ]
}

...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?

Community
  • 1
  • 1
Lanny Bose
  • 1,811
  • 1
  • 11
  • 16
  • Thanks this sounds like a nightmare. I've separated my registration changeset as per your suggestion, but to deal with the update scenario you outlined above, do you just remove cast_assoc and use a transaction (multi) or how would you approach updating related data it otherwise? – cnorris May 31 '18 at 21:12
  • In my app, I'm doing `phone_numbers` similar to how you're doing emails (just because that's my business logic). Rather than updating phone numbers, it's always either creating or deleting phone numbers with the `user_id`. That ruins the phenomenon of "one form that does everything", but I said oh well. The other approach you could take is a separate module that wraps everything in a transaction – Lanny Bose Jun 01 '18 at 00:46
  • I will say, I think `cast_assoc` makes a _lot_ more sense if your relationship is a `has_one`, since in an update you, by definition, want the old one to go away. – Lanny Bose Jun 01 '18 at 00:47