Hey, I know this has been discussed ad nauseum, but I couldn’t find a definitive answer or at least some consensus around this, so let me add it to the pile since I couldn’t find a discussion in this depth (pun intended). Given this example:
defmodule Forms.Form do
use Ecto.Schema
import Ecto.Changeset
schema "forms" do
field :status, Ecto.Enum, values: [:draft, :published], default: :draft
field :title, :string
has_many :questions, Forms.Question, on_replace: :delete
end
end
defmodule Forms.Question do
use Ecto.Schema
import Ecto.Changeset
schema "questions" do
field :prompt, :string
field :type, Ecto.Enum, values: [:short_text, :multiple_choice]
field :choices, {:array, :string}, default: []
belongs_to :form, Forms.Form
end
end
A Question only exists inside a Form. There’s no concept of a free-floating question, no other parent can claim it, and you’d never query questions independently. TLDR: assume this is the perfect shape for the schema hahah.
I want the form to be flexible while in draft, but strict on publish:
- While :draft, questions can be half-finished. prompt may be blank, and a :multiple_choice question doesn’t need choices yet.
- To move to :published, the form must have at least one question, every question must have a prompt, and every :multiple_choice question must have at least 2 entries in choices.
So the same has_many :questions association obeys two different validations depending on the parent’s status, and the draft to published transition has to re-validate the children against the stricter regime before flipping the state.
To make matters worse, let’s say the publish_form API supports patching the form before publishing. Sometimes the questions will contain changes, but sometimes it’s untouched. And we’re very concerned with performance, so a round trip to the db just to please the lib is not acceptable by our coding standards.
What’s the idiomatic way to write this publish_changeset? The best I got is:
def publish_changeset(%__MODULE__{} = form, attrs) do
form
|> cast([:title])
|> cast_assoc(:questions, with: &Forms.Question.complete_changeset/2)
|> validate_required([:title])
|> validate_length(:questions, min: 1)
|> revalidate_questions()
end
defp revalidate_questions(changeset) do
if get_change(changeset, :questions) do
changeset
else
case get_field(changeset, :questions) do
nil ->
changeset
|> add_error(:questions, "is required")
|> add_error(:status, "questions is required")
questions ->
# this part is clunky: I have to `put_assoc` so the errors are included
# in the changeset, it's cumbersome to check
all_complete? = Enum.all?(questions, &Forms.Question.complete?(&1))
if all_complete? do
changeset
else
add_error(changeset, :status, "incomplete questions")
end
end
end
end
But this is clunky to me. For one, Forms.Question.complete? is not a changeset, so we won’t have per-question error. If I turn this into a changeset it feels wrong, as the questions are not really changing? There’s also the weird double checking - one branch for if there are changes being passed and another one if the questions are not changing during posting - even though the rules are the same.
Is this the best I can do or is there a cleaner way?






















