woohaaha
Why are @valid_attrs @update_attrs and @invalid_attrs using atom/declared-types keys while controllers will pass along stringified params?
When a controller takes incoming params they are stringified like:
%{"count" => "5", "active" => "true"}
however when we generate a context phoenix it will use the types to be specific on the @valid_attrs, @update_attrs, and @invalid_attrs it pre-populates in the context tests:
@valid_attrs %{count: 5, ative: true}
However, a controller code typically passes the incoming params right into the context. Here is an example from the Programming Phoenix book:
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "#{user.name} created!")
|> redirect(to: Routes.user_path(conn, :index))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
As we can see, the user_params are immediately passed along into the context. Had we used the generators the @valid_attrs would be typed and not strigified.
Can anyone tell me why it’s like this and how do you ensure that the context test code mimics real behavior?
Do you sanitize params? Or do you change the test code? Something else?
Thank you
Most Liked
LostKobrakai
Imo params should never be touched before they’ve gone through a changeset. It doesn’t need to be a schemas changeset and it doesn’t need persistance, but it should go through a changeset. Personally I even feel this can belong in the controller as opposed to in a context.
But actually doing validation at the incoming edge of the system AND doing it at the outgoing edge for data consistency in persistence is a lot of work and might seem unnecessary for as long as both validations essentially do the same, which they would for scaffold-ed code. As soon as you’re no longer passing params along “as is” to the changeset of your schema it’s no longer one and the same on both edges so it can be a solid solution to validate params directly in the controller already and let the schema’s changeset only deal with the data consistency as opposed to also deal with casting values.
Also I suggest treating the scaffolding of generators much more as a stepping stone then a holy grail. It’s meant go get people up to speed with phoenix quickly and show them high level concepts like not putting business logic into controllers, but it’s not an architecture you shouldn’t modify because it’s already perfect. There are tradeoffs being done just for the fact that code needs to be generated, there are tradeoffs between showing as few code as possible to not overwhelm people vs. showing a full picture. What even is a full picture to people of widely different degrees of experience generally and with phoenix specifically.
thiagomajesk
Hi @LostKobrakai! I don’t mean to highjack the thread, but now you got me curious…
I have to be honest and say that is quite “natural” given the way that the project is scaffolded to just patch the params since they are the common denominator in both controllers, contexts, and schemas. So I wonder what are you doing in cases where you want to use part of the data from the params and compute the rest of the fields in the program before they get cast.
I have another thread dedicated to this subject, would you mind sharing your experience there?
thiagomajesk
I’m gonna give you a practical example that came to my mind while reading your answer, picture this:
You have to insert a new user in the database and you must fill a required “role” field which is an enum in the database (standard, moderator, and administrator).
Although the names of the roles can be customized by each organization, they always will be represented by one of the valid values of the underlying enum type, so: padawan (standard), knight (moderator), and master (administrator). Because of this requirement, you have to fetch the values from the database using another module.
Now you have the following business rule to apply:
Two users can’t have the same role, the first user to request the role gets the role, the next one will be demoted automatically if the role is taken. This happens until every role is distributed to all users.
I see at least three things that must be done in order for this operation to succeed:
- Validate if the user input is valid (username, email, etc)
- Validate if the “role” field is a valid enum type (standard, moderator, and administrator)
- Validate if the “role” field does not break any constraints (unique role by organization)
Considering the default scaffolded code, I imagine that this means that in the contexts either you:
1. Fetch the valid roles and patch the params map, calling the changeset function with all the required values that will be validated against the schema:
def create_user(params, organization_id) do
role = Roles.valid_organization_role(organization_id)
%User{}
|> User.changeset(Map.put(params, :role, role))
|> Repo.insert!
end
2. Call the changeset function that will cast only the input values. After that, you'd have to fetch and validate the computed values with the changeset in hands. This segments the validation logic between the context and the schema:
def create_user(params, organization_id) do
%User{}
|> User.changeset(params)
|> fetch_and_validate_organization_role(organization_id)
|> Repo.insert!
end
def fetch_and_validate_organization_role(changeset, organization_id) do
role = Roles.valid_organization_role(organization_id)
# using Ecto to validate the changeset and putting the changes in the changeset after?
end
3. Pass the params directly to the schema, cast the input values, and validate after you have cast the values. This IMHO breaks what I'd consider the boundary of the schema since you would have to call another context from the schema itself to validate the values (I'm not completely sure about this opinion):
def create_user(params, organization_id) do
%User{}
|> User.changeset(params, organization_id)
|> Repo.insert!
end
defmodule User do
def changeset(user, attrs, organization_id) do
user
|> cast(attrs, [:username, :email, :role])
|> validate_required([:username, :email, :role])
|> fetch_and_validate_organization_role(organization_id)
end
def fetch_and_validate_organization_role(changeset, organization_id) do
role = Roles.valid_organization_role(organization_id)
# using Ecto to validate the changeset and putting the changes in the changeset after?
end
end
4. Just don't use the schema to generate changeset and do everything in the context:
def create_user(params, organization_id) do
%User{}
|> user_changeset(params, organization_id)
|> Repo.insert!
end
def user_changeset(user, attrs, organization_id) do
user
|> cast(attrs, [:username, :email, :role])
|> validate_required([:username, :email, :role])
|> fetch_and_validate_organization_role(organization_id)
end
def fetch_and_validate_organization_role(changeset, organization_id) do
role = Roles.valid_organization_role(organization_id)
# using Ecto to validate the changeset and putting the changes in the changeset after?
end
IMHO, I guess that this architectural “flexibility” is both the strong and weak suit of Phoenix as a framework. If I’m being honest I have questioned myself a lot about this before and I don’t think I’ve got a clear answer to that yet. In fact, if you take a look at the thread I linked in my previous post, you’ll see that there are some suggestions about not using changesets in the schema but directly in the contexts, which would allow you to cast fetch, compute and sanitize incoming data without breaking the “boundaries” of each “layer”.







