caioaao
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?
Trending in Questions
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #library
- #deployment
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #elixirconf
- #channels
- #exunit
- #discussion
- #code-sync
- #podcasts
- #javascript
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixirconf-us
- #blog-post
- #elixir-ls
- #ai
- #phoenix_html
- #iex
- #graphql
- #genstage
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #hex
- #security
- #metaprogramming











Showing Posts 1 to 10- Show Best Posts
- Show All (oldest first)
- Show All (newest first)
Asd
I’d do it like this: in transaction I update questions given on incoming changes, then query questions for the form, then check if questions are valid. If so, change form and commit, otherwise rollback. That’s it
caioaao
That was my first thought, but it doesn’t make it less awkward:
force_changes: true? Hand-crafted validation with{:error, :invalid_questions}return (instead of per-question error)? It feels like neither are clean choicesMaybe I’m being pedantic here, but hey - the “best practices” tag isn’t the place for being 100% pragmatic eh?
krasenyp
I don’t understand why a round trip to the database is needed at all. If it’s just to check the data integrity when publishing then the database should be your last line of defence. You can have more than one functions producing changesets. While in draft, use
draft_changesetand when publishing -publish_changeset.Asd
It is inevitable. You need to read questions in order to check if they are valid for published form. This read->check->write sequence must be atomic and isolated too, so your only option is transaction.
caioaao
Ahhh, I guess you’re right. When I read your reply I thought of going from read->write(questions)->check->write to read->write(questions)->check->write, but I guess you could just do write(questions)->check->write since the
write(questions)would already return the data. Got it.caioaao
Yeah, sorry. I was trying to pre-answer some of the approaches I’ve seen, but I guess this doesn’t make much sense here anyway and ended up being a distraction
My question is really how to build the
publish_changesetLostKobrakai
I’d treat the draft as the input or “changes” to a new record tbh. To me that’s the more reasonable architecture over a random boolean flag.
Also keep
Ecto.Changeset.change/2,Ecto.Changset.put_assoc/embedin mind for applying changes programatically as opposed to as weakly typed external inputs, which need to be casted.caioaao
Yeah, I think in this example it’s the best answer - split
FormintoFormDraftandForm, and haveFormDraft.to_form_paramsthat passes it to theForm.publish_changeset.But there’s also cases where this wouldn’t be possible. Imagine if state had a higher cardinality, to the point that having different subtypes would be too much. Or just (as is my case) that it’s an existing code base and refactoring it like that would be too costly - maybe there are queries that need to return both, or there’s a lot of code that depends on this already. Is there a solution for the `publish_changeset` that’s more elegant or am I stuck with the workarounds since the modeling is not the best?
LostKobrakai
You do not need to tightly couple your changesets to forms. You can map between the changeset of your form and the changeset you use in the backend to talk to your database.
caioaao
I’m not sure if I follow. Where do you see this coupling with forms? In my example I’m only considering the back-end. So I’m considering there’s just a
Formscontext module that exposes acreate_form(draft_attrs) :: Formandpublish_form(form_id, attrs) :: Form- it’s agnostic to how it’s interacting with any front-end/api