Changeset Helpers – Ease working with nested Changesets and associations

Anyone who had to deal with change-ing nested associations knows how strugglesome and tedious it can get.

This library provides for now three convenient functions.

  1. Nest a change into a changeset:
ChangesetHelpers.put_assoc(account_changeset, [:user, :config, :address], address_changeset)

A function may also be provided as third argument, which receives the nested changeset (or data wrapped by a new changeset) as argument and returns the modified changeset.

For example in the code below I change an empty entity and add it into the association (typically when you want to insert a new row of inputs in a form to add an entity into a collection of persisted entities):

ChangesetHelpers.put_assoc(account_changeset, [:user, :articles],
  &(Enum.concat(&1, [%Article{} |> Ecto.Changeset.change()])))
  1. Change a nested association:
{account_changeset, address_changeset} =
  ChangesetHelpers.change_assoc(account_changeset, [:user, :user_config, :address])
{account_changeset, address_changeset} =
  ChangesetHelpers.change_assoc(account_changeset, [:user, :user_config, :address], %{street: "Foo street"})

A tuple is returned containing the modified root changeset and the association changeset.

  1. Check whether a given field is different between two changesets:
{street_changed, street1, street2} =
  ChangesetHelpers.diff_field(account_changeset, new_account_changeset, [:user, :user_config, :address, :street])

It’s quite niche but I still wanted to share the work with you :grin:

10 Likes

Added fetch_field(changeset, keys) and fetch_change(changeset, keys) (and their bang ! versions).

street = ChangesetHelpers.fetch_field!(account_changeset, [:user, :config, :address, :street])
1 Like

Added add_error(changeset, keys, message, extra \\ []) to add an error in a nested changeset.

ChangesetHelpers.add_error(account_changeset, [:user, :config, :error_key], "An error message")

I added a changeset utility function raise_if_invalid_fields(changeset, fields).

For example:

changeset
|> validate_inclusion(:cardinal_direction, ["north", "east", "south", "west"])
|> raise_if_invalid_fields([:cardinal_direction])

As its name suggests, the function raises if one of the given fields was provided with an invalid value.

Motivation/use case:

Say you have a dropdown list from which a user may select a value (like a <select> element). If the change is not included in the list, what happened?
It’s either a bug in the application, or a script, a malicious user or bot that bypassed the form control inputs.

As the very first version of my application will definitely have bugs, I want to raise to make sure that I don’t miss such a bug (if it were a bug); because if I just add a changeset error, I will not be automatically alerted of the bug, and have to rely on the user to report such cases.

If scripts or bots are abusing my forms, I can just remove the function call and let the error in the changeset (you don’t want to let your application because of a user submit).

Just an idea. Any thoughts welcome:)

2 Likes

Added a changeset validation function allowing to compare two fields:

validate_comparison(changeset, :start_time, :lt, :end_time)

assert [start_time: {"must be less than 10:00:00", [validation: :comparison]}] = changeset.errors
assert [start_time: :comparison, end_time: :comparison] = changeset.validations

or a change with a value:

validate_comparison(appointment_changeset, :start_time, :gt, ~T[07:00:00])

assert [start_time: {"must be greater than 07:00:00", [validation: :comparison]}] = changeset.errors
assert [start_time: :comparison] = changeset.validations

Works for Date, Time, DateTime, NaiveDateTime and numbers.

Added a changeset validation function to validate lists (array schema fields)

changeset =
  %Appointment{}
  |> Appointment.changeset(%{days_of_week: [1, 3, 8]})
  |> validate_list(:days_of_week, &Ecto.Changeset.validate_inclusion/3, [1..7])

assert [days_of_week: {"is invalid", [validation: :list, index: 2, validator: :validate_inclusion]}] = changeset.errors
assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations

Version 0.17 is released:

  • dropped raise_if_invalid_fields/2

  • added field_fails_validation?/3 and field_violates_constraint?/3

Use case: sometimes instead of returning changeset errors to the client, you want to return a specialized response for some of the errors that happened. This is especially useful for returning specialized GraphQL objects representing errors in some cases, instead of a set of generic changeset errors.

Example:

case Accounts.register_user(input) do
  # ...
  {:error, changeset} ->
    email_already_used? =
      ChangesetHelpers.field_fails_validation?(changeset, :email, :unsafe_unique)
      || ChangesetHelpers.field_violates_constraint?(changeset, :email, :unique)

    if email_already_used? do
      # ...
    else
      {:error, changeset}
    end
end
1 Like