AdequateErrors - Overcoming limitation of Rails model errors API
Over the years I encountered many issues related to ActiveModel::Errors
API. After looking at the Rails source, I realized the original design was the root cause. errors
was originally just a hash of array of String
, which worked for simple requirements, but not for more complex ones.
In April I started collecting use cases, and study Rails source. Last month I finally put my hands on implementing a solution: a gem to apply object-oriented design principles to make each error an object, and provide new set of APIs to access these objects. I call it AdequateErrors.
AdequateErrors can be accessed by calling model.errors.adequate
. It co-exists with existng Rails API, so nothing will break. But what issues does it solve? Let me list them one by one:
Query on errors using where
Imagine we need to access the empty
error on any attributes:
model.errors.details.each {|attribute, errors|
errors.find {|h|
h[:error] == :empty
}
}
AdequateErrors provides a where
method. Now we can stop using loops, and write complex queries:
model.errors.adequate.where(type: :empty)
model.errors.adequate.where(attribute: :title, type: :empty)
model.errors.adequate.where(attribute: :title, type: :empty, count: 3)
This returns an array of Error objects. Simple.
Access both the message and details of one particular error
If one attribute has two foo_error
and one bar_error
, e.g.:
# model.errors.details
{:name=>[{error: :foo_error, count: 1}, {error: :bar_error}, {error: :foo_error, count: 3}]}
How do you access the message on the third particular error? With the current implementation, we have to resort to using array indexes:
model.errors.messages[:name][2]
Or we can call generate_message
to recreate a message from the details, but that’s also tedious.
With AdequateErrors, we won’t have this problem. Error is represented as an object, message and details are its attributes, so accessing those are straightforward:
e = model.errors.adequate.where(attribute: :title, type: :foo_error).first
e.message # full message
e.options # similar to details, where meta informations such as `:count` is stored.
Lazily evaluating message for internationalization
@morgoth mentioned this issue that when you’re adding error, it’s translated right away.
# actual:
I18n.with_locale(:pl) { user.error.full_messages } # => outputs EN errors always
# expecting:
I18n.with_locale(:pl) { user.error.full_messages } # => outputs PL errors
I18n.with_locale(:pt) { user.error.full_messages } # => outputs PT errors
Taking this into consideration, AdequateErrors lazily evaluates messages only when message
is called.
Error message attribute prefix
Not all error messages start with the attribute name, but Rails forces you to do this. People have developed hacks to bypass this. Others simply assigned errors to :base
instead of the actual attribute. This is ugly.
Here is AdequateErrors’ solution. It has its own namespace in the locale file, and instead of the global default format "%{attribute} %{message}"
, the prefix is moved into each individual entries:
en:
adequate_errors:
messages:
invalid: "%{attribute} is invalid"
inclusion: "%{attribute} is not included in the list"
exclusion: "%{attribute} is reserved"
All built-in error types have been converted into this. If one wishes to have prefix-less error, simply have its entry in locale file without the %{attribute}
.
Just less error prune code
I remember when I first learned about Object-Oriented design principle in uni, there was this example of payroll system. In the system, one array stores account name and another array stores account number. Whenever we need to delete an account, we need to manipulate both arrays. Further more, if we need to add a new attribute, we need to add a third array. It is very clear that objectifying this system can make it simpler and less error-prone.
This is what the current Rails errors implementation looks like:
def copy!(other) # :nodoc:
@messages = other.messages.dup
@details = other.details.dup
end
def clear
messages.clear
details.clear
end
def delete(key)
attribute = key.to_sym
details.delete(attribute)
messages.delete(attribute)
end
This being similar to the case I mentioned above, really can benefit from an object-oriented approach.
Conclusion
If you are a long-time Rails developer, chances are you have met similar issue before, please try this gem. If you have other usecases that you wish to improve on, I would like to know and see if it can be added into the gem. Happy hacking!