Dynamically calling changesets

I’ve written a few changeset functions, each for the corresponding field in the user schema. I’m now thinking about how to best tie them together for the registration_changeset. I want to do that dynamically since I want to experiment with different ways a user can register, with or without email address, password, and/or phone number.

These are my current changeset functions for some of the fields in the schema:

def username_changeset(user, params \\ %{}) do
  user
  |> cast(params, [:username])
  |> validate_required([:username])
  |> validate_length(:username, min: 3, max: 20)
  |> validate_format(:username, ~r/^[a-zA-Z0-9_]*$/)
  |> update_change(:username, &String.downcase/1)
  |> unique_constraint(:username)
end
  
def password_changeset(user, params \\ %{}) do
  user
  |> cast(params, [:password])
  |> validate_required([:password])
  |> validate_length(:password, min: 6, max: 100)
  |> put_password_hash()
end
  
def email_address_changeset(user, params \\ %{}) do
  user
  |> cast(params, [:email_address])
  |> validate_required([:email_address])
  |> validate_format(:email_address, ~r/@/)
  |> unique_constraint(:email_address)
end
  
def phone_number_changeset(user, params \\ %{}) do
  user
  |> cast(params, [:phone_number])
  |> validate_required([:phone_number])
  |> unique_constraint(:phone_number)
end
  
def avatar_changeset(user, params \\ %{}) do
  cast_attachments(user, params, [:avatar])
end
  
def location_changeset(user, params \\ %{}) do
  user
  |> cast(params, [:location])
  |> validate_required([:location])
  |> put_district()
end

And I am considering writing something like this for the registration_changeset:

def registration_changeset(user, params \\ %{}, fields) do
  Enum.reduce(fields, %Ecto.Changeset{}, fn field, acc ->
    merge(acc, apply(__MODULE__, :"#{field}_changeset", [user, params]))
  end)
end

where merge/2 is imported from Ecto.Changeset.

Is this overly complicated? Thanks

2 Likes

For me it’s way to much complex, because after all you have to combine these functions together, so it’s too much boilerplate, because you have to use cast and validation_required over and over again.

I would bundle them in some logical sense and use one or more functions, but that are strictly related to your business logic.

1 Like

Since cast and validation_required are just operations on maps, I don’t think that it hurts performance very much, but I see what you mean. The reason I want to do this is because otherwise I would have too many changesets since there are many combinations of the fields a user can provide, 16 (5C2 + 5C3 + 5C4 + 5C5)?

I’m not sure I understand the logic here - you conditionally run the function, if the param was provided, but inside you specify it’s required. Wouldn’t it be the same to run just one function with all the params optional?

Also, all the built-in validation functions won’t run if the param was not provided (they only apply to the changes part of the changes, not the data field), so that is already covered for optional fields.

The only functions that would potentially need changes to run optionally, only when required, are put_district/1 and put_password_hash/1. A function, I’ve been using in cases like this is something like:

def run_if_changed(changeset, field, fun) do
  if get_change(changeset, field) do
    fun.(changeset)
  else
    changeset
  end
end

This allows you to call |> run_if_changed(:location, &put_district/1), which will run the put_distirct function on the changeset, only when the location changed.

1 Like

I’m not sure I understand the logic here - you conditionally run the function, if the param was provided, but inside you specify it’s required. Wouldn’t it be the same to run just one function with all the params optional?

Yeah, I guess it would. Haven’t thought about it. Thanks!

I’m still having troubles though with choosing which fields to cast, I don’t want to cast all the fields supplied by the user (what if they don’t exist in the schema), and restricting them to only required_fields removes the ability to add optional information (like location or avatar), so I’ve separated those into avatar and location changeset functions. Is there maybe a better way?

def registration_changeset(user, params \\ %{}, required_fields) do
  user
  |> cast(params, required_fields)
  |> validate_required(required_fields)
  |> username_check()
  |> email_address_check()
  |> phone_number_check()
  |> password_check()
  |> put_password_hash()
end

def avatar_changeset(user, params \\ %{}) do
  cast_attachments(user, params, [:avatar])
end

def location_changeset(user, params \\ %{}) do
  user
  |> cast(params, [:location])
  |> put_district()
end

I’ve also replaced all <field_name>_changeset functions with <field_name>_check functions, I don’t know why though…

def username_check(changeset) do
  changeset
  |> validate_length(:username, min: 3, max: 20)
  |> validate_format(:username, ~r/^[a-zA-Z0-9_]*$/)
  |> update_change(:username, &String.downcase/1)
  |> unique_constraint(:username)
end

def password_check(changeset) do
  changeset
  |> validate_length(:password, min: 6, max: 100)
end

def email_address_check(changeset) do
  changeset
  |> validate_format(:email_address, ~r/@/)
  |> unique_constraint(:email_address)
end

def phone_number_check(changeset) do
  changeset
  |> unique_constraint(:phone_number)
end

The only functions that would potentially need changes to run optionally, only when required, are put_district/1 and put_password_hash/1. A function, I’ve been using in cases like this is something like:

put_district/1 and put_password_hash/1 are defined like this

defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: pw}} = cs) do
  put_change(cs, :password_hash, Comeonin.Bcrypt.hashpwsalt(pw))
end
defp put_password_hash(changeset), do: changeset

defp put_district(%Ecto.Changeset{valid?: true, changes: %{location: location}} = cs) do
  put_change(cs, :district, Store.District.from_location(location))
end
defp put_district(changeset), do: changeset

so I guess I can just pipe the changeset in them without wrapping them into run_if_changed/3?

Should I maybe just add all the fields that might be provided to the cast/3 function in the registration_changeset?

def registration_changeset(user, params \\ %{}, required_fields) do
  user
  |> cast(params, ~w(username password email_address phone_number)a)
  |> validate_required(required_fields)
  |> username_check()
  |> email_address_check()
  |> phone_number_check()
  |> password_check()
  |> put_password_hash()
end