All talk but no code...

makes lulalala a bluff boy

AdequateErrors - Overcoming limitation of Rails model errors API

| Comments

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!

Rails

Comments

comments powered by Disqus