caioaao

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?

Showing Posts 1 to 10

Asd

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

caioaao OP

That was my first thought, but it doesn’t make it less awkward:

  1. It adds a round-trip to the db - if performance is critical, this is a deal breaker
  2. “check if questions are valid”: how would you do that? Use a new changeset with force_changes: true? Hand-crafted validation with {:error, :invalid_questions} return (instead of per-question error)? It feels like neither are clean choices

Maybe I’m being pedantic here, but hey - the “best practices” tag isn’t the place for being 100% pragmatic eh? :sweat_smile:

krasenyp

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_changeset and when publishing - publish_changeset.

Asd

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

caioaao OP

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

caioaao OP

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 :slight_smile:

My question is really how to build the publish_changeset

LostKobrakai

LostKobrakai

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/embed in mind for applying changes programatically as opposed to as weakly typed external inputs, which need to be casted.

caioaao

caioaao OP

Yeah, I think in this example it’s the best answer - split Form into FormDraft and Form, and have FormDraft.to_form_params that passes it to the Form.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

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

caioaao OP

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 Forms context module that exposes a create_form(draft_attrs) :: Form and publish_form(form_id, attrs) :: Form - it’s agnostic to how it’s interacting with any front-end/api

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
kszambelanczyk
Hello! Could someone please give me a help/sample code, how to delete a file from s3 using waffle/waffle_ecto from Phoenix app. I creat...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New
psy-q
I’m trying to set up Emacs with elixir-ls via lsp-mode and credo via Flycheck. This should mostly be preconfigured as Flycheck picks up c...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge & Solve. They are GUI (Emerge) and State management (S...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews