Best practice for association changesets

I am an experienced dev, but pretty new to elixir. I keep finding myself going back and forth between a couple options for how to construct a changeset that will create a new record with one or more associations.

The following small ecto schema module presents the two options

defmodule MyProject.Quizzes.QuizQuestion do
  use Ecto.Schema
  import Ecto.Changeset

  schema "quiz_questions" do
    belongs_to :quiz, MyProject.Quizzes.Quiz
    belongs_to :question, MyProject.Questions.Question


  @doc false
  def changeset(quiz_question, attrs) do
    |> cast(attrs, [:quiz_id, :question_id])
    |> validate_required([:quiz_id, :question_id])
    |> foreign_key_constraint(:quiz_id)
    |> foreign_key_constraint(:question_id)

  def create(%MyProject.Quizzes.Quiz{} = quiz, %MyProject.Questions.Question{} = question) do
    |> change()
    |> put_assoc(:quiz, quiz)
    |> put_assoc(:question, question)
    |> foreign_key_constraint(:quiz_id)
    |> foreign_key_constraint(:question_id)

In this example I have three models: Quiz, Question, and a join table QuizQuestions that combines them. I have two functions in the module:

  • change: this takes in an attrs map, casts the foreign key id, requires them, and adds foreign key constraints
  • create: takes in instances of the related schemas (Question and Quiz), uses put_assoc to build the association, then adds foreign key constraints

Both of these approaches let me create the new QuizQuestion struct that is Repo.insertable, but I still have some questions.

  • Is one approach more idiomatic and why?
  • Should I prefer one case to the other?
  • What are the tradeoffs?
  • Is it bad practice to use structs defined in other core modules as arguments to functions (like I did in create when I accept Quiz and Question structs)?

I’m going to give you the less-than-entirely-helpful “it depends” :stuck_out_tongue:

Both approaches can be useful, depending on exactly where quiz and question are coming from:

  • if both are coming from the user, an approach that casts them could be useful to make sure they’re converted to the ID type etc
  • if neither are coming from the user, the full put_assoc approach is fine
  • it’s also possible to have a mix - maybe the function is being called from a URL like /quiz/1234/questions where the Quiz to associate is known but the question_id to use is from the user

In the “neither from the user” case, you might even shorten things further if you don’t want / need to display foreign key errors as changeset errors:

def just_create_already(quiz, question) do
  %__MODULE__{quiz: quiz, question: question}

This can be passed to Repo.insert directly, no changeset required.

Two general things to think about:

  • where is the input coming from? Does it need to be type-cast?
  • where should errors appear? Are they meaningful?

For instance on the second point, a field that a user could leave blank might have validate_required on it so they could be told they’re making a mistake. OTOH a field that the program fills in that shouldn’t ever be blank might just have a bare NOT NULL in the DB so failing to fill it in crashes / fails.

1 Like