-
Notifications
You must be signed in to change notification settings - Fork 29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Proposal] Add validation messages to Specs #83
base: master
Are you sure you want to change the base?
Conversation
@kieraneglin This is an amazing writeup! Thanks so much for putting the time in. I've been digesting this some. I'll leave some more comments soon. But I wanted you to know that I'd seen this and I'm thinking through it. |
Thanks for thinking this through. A few notes: I like the terseness of just using a list for a chain of specs. However, given that norm already matches on tuples and atoms, my first thought might have been that it was matching a list rather than being a special chain syntax. I guess the syntax just feels a little surprising to me and I might expect something more explicit like your mention of using I have been using I could see this working with your API by using a simple helper like the following: def conform_changeset(changeset, attribute_name, spec) do
case changeset
|> apply_changes()
|> conform(spec) do
{:error, %{message: message}} -> Ecto.Changeset.add_error(attribute_name, message)
_ -> changeset
end
end Using some of your examples, but with the good_messages_spec = specs([
spec(is_binary, "must be a string"),
spec(&(String.length(&1) in 3..15, "must be between 3 and 15 characters"),
spec(&(!String.contains(&1, "heck")), "can't contain any forbidden words")
])
user
|> User.changeset(attrs)
|> conform_changeset(:username, schema(%User{username: good_messages_spec}))
# returns a changeset Those are validations that I would probably have done via the changeset, but just trying to demonstrate usage. One difference between this and changesets is that it fails fast. Other than for constraints, changesets will validate everything in one go. To match that behavior, perhaps def conform_changeset(changeset, attribute_name, spec) do
case changeset
|> apply_changes()
|> conform(spec) do
{:error, %{message: message}} ->
Ecto.Changeset.add_error(attribute_name, message)
{:error, errors} ->
Enum.reduce(errors, changeset, fn %{message: message}, changeset ->
Ecto.Changeset.add_error(attribute_name, message)
end)
_ ->
changeset
end
end If failing fast was not the desire, it could be set on the spec... good_messages_spec = specs(
[
spec(is_binary, "must be a string"),
spec(&(String.length(&1) in 3..15, "must be between 3 and 15 characters"),
spec(&(!String.contains(&1, "heck")), "can't contain any forbidden words")
],
false # do not fail fast
) In some ways, it might be purer to specify whether to fail fast as an option to I understand if changesets are irrelevant to your intent here. Either way, nice error messages would be a welcome addition to the library. Thanks! |
After posting the above, I noticed the "Context" link in the description above. haha |
@baldwindavid thank you for the input! With regards to changesets specifically, I have a few more thoughts. To confirm your findings, this was initially planned to tie into changesets to allow unification of our validation logic via Norm (for others, direct link to my first comment). To follow up on the failing-fast comments, I still think that failing fast in the context of a changeset is the preferable mode of operation. I also acknowledge that my thoughts on this are heavily influenced since there's a specific use case that I'm working toward. To remove ambiguity, when I say "fail fast" I mean that within the context of a individual chained spec - it would not affect the behavior of the rest of the changeset or even other specs within the schema. As a few contrived examples: def changeset(record, params) do
record
|> cast(params, @allowed_params)
|> validate_required([:username, :email])
|> validate_schema(valid_record_schema)
|> validate_format(:email, ~r/@/) # I would still expect this to run, independent of `valid_record_schema`'s fail-fast behaviour
end In this case, the proposed new API + my basic changeset helper wouldn't alter the behavior of the subsequent validations. Since failing fast is scoped to a single chained spec within a schema, other Norm schema validations would still run like so: def changeset(record, params) do
user_schema = schema(%{
username: chained([
spec(is_binary, "must be a string"),
spec(&(String.length(&1) in 3..15, "must be between 3 and 15 characters")
]),
email: spec(at_symbol_spec, "must contain a @"),
first_name: spec(is_binary, "must be a string")
})
record
|> cast(params, @allowed_params)
|> validate_schema(user_schema)
end
changeset(record, %{ username: 1, email: "not-real", first_name: "Kieran" })
# would roughly return
# %Changeset{
# valid?: false,
# errors: [
# { username: "must be a string" },
# { email: "must contain a @" },
# ]
# } Edit: I forgot to account for what The reason I'm partial to this behavior is that it gives the user enough context to start solving all present issues but without feeding potentially invalid data down the chained spec. It also prevents an overload of redundant errors ("Username must be present" + "Username must be a string" + "Username must be at least 3 characters" being shown to the user all at once isn't ideal). I don't think it applies in this case, but as a backup there's nothing stopping you from having multiple I'd be interested to hear your thoughts on this! |
@kieraneglin - Thanks for the message. You're right that it is flexible enough that one can choose to break these into separate specs outside of After further consideration, introducing an option for whether to fail fast or not probably just adds more mental overhead than it is worth, so it is probably best to just choose one or the other. Even so, I would probably still lean toward not failing fast (returning multiple errors) because that is how Ecto changesets do it. Additionally, I actually prefer returning multiple errors for something like an invalid password. Either way is workable though. |
@baldwindavid I'm on mobile so this will be a pretty minimal example, but another thing worth mentioning is that this doesn't break the existing mechanism for chaining specs. It may not solve your case, but this is yet another option for chain composition: username_spec = chained([
spec(must_exist and is_binary and is_right_length, "must be correct"),
spec(no_bad_words, "can't contain bad words")
]) Fast failure still happens but it allows you to group specs as needed. |
@kieraneglin Yep, I really like the addition of the validation message per |
I like the idea of having a way to compose specs together. In fact, I started adding an Overall I like the idea of adding custom messages with |
Hey, guys! |
Its in my stack to evaluate. Just have to pop enough items to get back down to it |
Context: #76
Problem statement
Norm provides fantastic validation utilities but the errors generated aren't end-user friendly, leading to a split in our validation tooling. In order to use Norm for validation across our stack we'd need the ability to have human-readable errors generated while supporting localization.
Design goals
Clarity
Supplied validation messages should be specific to a logical validation unit. A message should give context about a failure rather than serving as a catch-all statement.
Compatibility
Validation messages should be opt-in and should not break existing implementations.
Validation safety
Specs should fail fast so that potentially invalid data isn't fed to further specs for the same attribute.
Attempted approaches
Return tuples
A spec can now optionally return a tuple of
{boolean(), String.t()}
whereboolean()
is the result of the validation andString.t()
is a validation message.This approach created ugly specs (think
spec(&({String.length(&1) > 0, "must not be blank}))
), didn't work for built-ins likeis_integer
, and made validation message composition very difficult.Per-spec messages
A spec can optionally accept a second argument of a validation message. This solves the shortcomings of the above approach, but fails on the design goal of clarity. To address this, I've added the ability to create a chained spec from a series of simpler specs. This is the approach I ended up going with.
Proposed approach
API
Upsides
Downsides
Seemingly ambiguous syntax
Based on some pattern matching features of Norm as well as pattern matching with Elixir in general, you might write a spec like this:
This could be alleviated by using a specific method/type to disambiguate chained specs. Something like this:
Chained specs are exclusively "and"
Norm supports
and
/or
for composing specs. While these are supported within a single spec in the chain, the individual specs are treated with "and" logic. Example:This means you can't have
or
logic between elements on a chained spec.In practice I've found this to be both logically clear and desirable, but I'm use there's a use case that would be negatively impacted by this.
Not 100% backward-compatible
Even if you don't use a message, the
message
key still exists in the returned spec struct with a value ofnil
. While this doesn't modify any logic or names within the spec struct, strict pattern matching checks could fail. This is compounded by the fact that Norm, being a validation library, allows people to confidently check for the exact structure of a validation response in their tests or even their application code.I'm sure there's a path forward here, but it isn't addressed in this initial POC.
Wrapping up
This PR is meant to be both a low-effort code change and a discussion around different approaches and their pros + cons.
The code changes work as outlined, but some tests need to be updated (due to that backwards-compatibility issue) and many more tests need to be written if there's interest in this approach.