Skip to content
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

Model error as object #32313

Merged
merged 22 commits into from
Apr 24, 2019
Merged

Conversation

lulalala
Copy link
Contributor

@lulalala lulalala commented Mar 21, 2018

Summary

Add ActiveModel::Error class for encapsulating a single error. It handles message generation and details access.
Utilize this in Errors class, changing it from a hash based interface, to an array of Error objects. Add more flexible query methods like where.

Introduction

ActiveModel#errors interface is currently not very object oriented. For some complex use cases, this design made it a bit tedious to use. I feel these issues can be remedied by encapsulating each individual error as an object.

Last year I implemented AdequateErrors gem to do this, and it solved many of my problems. I later found out that back in 2016, @eprothro suggested the same idea too on the core mailinglist, and Rafael was positive about this. So I made this PR.

I've made it to respect Rails’ deprecation policy. As this contains breaking changes, can this go straight into Rails 6.0?

I will fix other specs/add doc/performance tuning once interface is finalized.

Benefits

More flexible query interface such as where

It’s easy to query one particular object using the where clause.

model.errors.where(:name, :foo, bar: 3).first

delete, add, added?, where all share the same method signature (attribute, type, options). So we are able to delete specific errors now:

model.errors.delete(:name, :too_powerful, level: 9000)

Testing is more precise and flexible

We can now test if “foo” error exists, regardless of its options hash.

model.errors.add(:name, :too_powerful, level: 9000)

model.errors.added?(:name, :too_powerful, level: 9000) # returns true
model.errors.added?(:name, :too_powerful) # will be false in the past, but be true now.

In the past, added? works by re-render the message and compare it against current message. Therefore if level is not provided, it will return false. In the PR, added? only compare Error's attributes against what's provided, so it can be more general or more specific depending on the needs.

Get message of corresponding details of one particular error

If you saw that name 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 back track 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 OO, 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.where(:name, :foo_error).last
e.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

This PR addresses this by lazily evaluating messages only when message is called.

Opens possibility to advanced modding

Once errors are objects, it’s easy to add functionality on top of them. We can have custom methods to disable global attribute prefix on error’s full messages.

List of API changes

[]
unchanged, deprecated (use messages_for instead)

as_json, blank?, clear, count, empty?
unchanged

add
unchanged

added?
mostly unchanged, for the one change see "Testing is more precise and flexible"

delete
extended, so we can give more specific condition such as error type or options.

each
If we use each{|attr,msgs|} then it behaves the same as before. Deprecated
If we use each{|error|} then it loops through Error array.

full_message
deprecated as it is no longer needed.
unchanged message is generated in Error.

full_messages, full_messages_for
unchanged

messages_for
new, to replace deprecated []

where
new, query for error objects
generate_message
deprecated Part of Error as message is generated there.

has_key? key?, keys
deprecated

include?, size, to_hash, to_xml, to_a
unchanged

values
deprecated

import
new, imports one error as a nested error. Useful in Form Object and nested attribute error importing.

messages, details
unchanged

Some questions I have

I am not sure what's the policy for marshal across versions. marshal_dump and marshal_load are implemented, but do they have to support marshaling across Rails versions?

This has been done.

Can we deprecate to_xml? I bet is rarely used, and can exists as a gem.

@pixeltrix
Copy link
Contributor

@lulalala is there a reason why you can't emulate the hash-based API?

@lulalala
Copy link
Contributor Author

@pixeltrix for the hash like APIs, my thoughts are:

  1. For enumerable methods such as each, I choose to forward them to the error array. Keeping it to enumerate as hash seems to defeat the purpose.
  2. For [] and to_hash, they are emulated.
  3. For values, I removed it. I feel it was there just so errors feels like a hash. It is not useful because having an array of partial error message without knowing each message's respective attribute is useless.

@rafaelfranca
Copy link
Member

Everything that was removed needs to be deprecated first. We don't make breaking changes without deprecation, even in major versions.

marshal_dump and marshal_load are implemented, but do they have to support marshaling across Rails versions?

Yes, we try very hard to make possible that an old Rails version can read data from the new version and also the opposite. This make possible for applications to slowly rollout new versions to production.


module ActiveModel
# Represents one single error
# @!attribute [r] base
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use yarn, we use rdoc, so we need to follow the Rails documentation guideline.

@lulalala
Copy link
Contributor Author

Everything that was removed needs to be deprecated first. We don't make breaking changes without deprecation, even in major versions.

Cool. I'll add those back then. How about semantic change such as each?

Yes, we try very hard to make possible that an old Rails version can read data from the new version and also the opposite. This make possible for applications to slowly rollout new versions to production.

Got it, but I still have a question. When marshal_load was added (SHA b3dfd7d), I don't see it tried to accommodate Rails 3 or 4, or am I wrong?

@rafaelfranca
Copy link
Member

Got it, but I still have a question. When marshal_load was added (SHA b3dfd7d), I don't see it tried to accommodate Rails 3 or 4, or am I wrong?

Usually we only think about backward compatibility between two close release. In that case 5.1 and 5.2. Right now it would be 6.0 and 5.2

@rafaelfranca
Copy link
Member

How about semantic change such as each?

Can we introduce a new method with the new semantic and keep the old method with the previous semantic but with deprecation?

@lulalala
Copy link
Contributor Author

Can we introduce a new method with the new semantic and keep the old method with the previous semantic but with deprecation?

I realized there is a better solution. I check arity of the block passed to each. If arity is 1, behave the new way. We can put deprecation notice when the old way is triggered. Thoughts?
https://github.com/rails/rails/pull/32313/files#diff-fdcf8b65b5fb954372c6fe1ddf284c78R196

@rafaelfranca
Copy link
Member

rafaelfranca commented Mar 22, 2018

That is a good way. 👍

@lulalala
Copy link
Contributor Author

lulalala commented Mar 26, 2018

I have added back existing methods, some with deprecation warning (free to discuss about which to deprecate). The each now should be compatible with old hash block.

The YAML and marshal dumps are also made compatible with past versions. I added tests for each.

I am starting to look into other tests which broke, and I learned a lot about what interface are needed. For example I added group_by_attribute because it is suitable for many occasions.

I'll update in the future once all tests are fixed. After that I'll add doc and optimize :)

@lulalala
Copy link
Contributor Author

lulalala commented Apr 3, 2018

I think I have fixed almost all tests (the remaining few seems to be sporadic).

I discovered there are many cases of errors[:foo] << 'bar'. We may be able to put a deprecation warning on that, but would involves having a custom array class which overrides << just for that purpose. I hope that's not needed.

Another behavior change is that details will not auto populate with empty array if it is access with a missing key.

If the deprecation proposal here is finalized, I'll start adding code so tests won't have deprecation warning all over the place.

@lulalala
Copy link
Contributor Author

Hi @tenderlove, we have met in Ruby Kaigi, and you mentioned that for emulated message arrays, I should freeze those so attempts to append to them would fail. This is now added. Cheers! (Sorry I forgot who was sitting next to you who said I can also ping him as well)

@lulalala lulalala force-pushed the model_error_as_object branch 2 times, most recently from 72db2d6 to 8344612 Compare September 24, 2018 15:41
@lulalala
Copy link
Contributor Author

lulalala commented Sep 29, 2018

Hi @Larochelle, I see that you made a few changes lately to errors.rb. I am slowly trying to add the change you made to here, but after some digging, I am feeling that maybe we can DRY the logic a bit, as generate_message (here as Error#message and full_message) shares a lot of logic. Would you be interested in working with me on this together? Thanks!

sethboyles added a commit to cloudfoundry/cloud_controller_ng that referenced this pull request Jan 7, 2022
* See rails/rails#32313 for more info

Co-authored-by: Seth Boyles <sboyles@pivotal.io>
Co-authored-by: Tom Viehman <tviehman@pivotal.io>
lulalala added a commit to lulalala/rubocop-rails that referenced this pull request Jan 10, 2022
These are deprecated in Rails 6.1 and will be removed in Rails 7.
See rails/rails#32313 for details.

The cop acts in two modes:

For files under `/models` directory, any `errors` call,
whether with receiver or not, will be checked.
For general files, only `errors` calls with receivers will be checked.

E.g. `errors[:bar] = []` is without receiver.
It will record an offense if it is a model file.
It will not record an offense if it is other general fie.

This is to reduce false-positives,
since other classes may also have a `errors` method.
sweinstein22 added a commit to cloudfoundry/cloud_controller_ng that referenced this pull request Jan 13, 2022
* Update ActiveModel errors for Rails 6.1
* See rails/rails#32313 for more info

Co-authored-by: Seth Boyles <sboyles@pivotal.io>
Co-authored-by: Michael Oleske <moleske@pivotal.io>
Co-authored-by: Tom Viehman <tviehman@pivotal.io>
Co-authored-by: Sarah Weinstein <sweinstein@pivotal.io>
lulalala added a commit to lulalala/rubocop-rails that referenced this pull request Mar 6, 2022
These are deprecated in Rails 6.1 and will be removed in Rails 7.
See rails/rails#32313 for details.

The cop acts in two modes:

For files under `/models` directory, any `errors` call,
whether with receiver or not, will be checked.
For general files, only `errors` calls with receivers will be checked.

E.g. `errors[:bar] = []` is without receiver.
It will record an offense if it is a model file.
It will not record an offense if it is other general fie.

This is to reduce false-positives,
since other classes may also have a `errors` method.
lulalala added a commit to lulalala/rubocop-rails that referenced this pull request Mar 6, 2022
These are deprecated in Rails 6.1 and will be removed in Rails 7.
See rails/rails#32313 for details.

The cop acts in two modes:

For files under `/models` directory, any `errors` call,
whether with receiver or not, will be checked.
For general files, only `errors` calls with receivers will be checked.

E.g. `errors[:bar] = []` is without receiver.
It will record an offense if it is a model file.
It will not record an offense if it is other general fie.

This is to reduce false-positives,
since other classes may also have a `errors` method.
xlgmokha added a commit to xlgmokha/saml-kit that referenced this pull request Mar 30, 2022
xlgmokha added a commit to xlgmokha/saml-kit that referenced this pull request Mar 30, 2022
LeisuPan added a commit to LeisuPan/client_side_validations that referenced this pull request Jul 16, 2022
richardstewart0213 added a commit to richardstewart0213/rubyonrails-client_side_validations that referenced this pull request Nov 4, 2022
mikejohn857 added a commit to mikejohn857/client_side_validations-ruby that referenced this pull request Nov 25, 2022
will-gant pushed a commit to sap-contributions/cloud_controller_ng that referenced this pull request Dec 16, 2022
* Update ActiveModel errors for Rails 6.1
* See rails/rails#32313 for more info

Co-authored-by: Seth Boyles <sboyles@pivotal.io>
Co-authored-by: Michael Oleske <moleske@pivotal.io>
Co-authored-by: Tom Viehman <tviehman@pivotal.io>
Co-authored-by: Sarah Weinstein <sweinstein@pivotal.io>
toddkummer added a commit to RockSolt/filterameter that referenced this pull request May 7, 2024
With the switch to ActiveModel::Error in Rails 6.1, the hash-based access can be retired.

Reference:
rails/rails#32313
toddkummer added a commit to RockSolt/filterameter that referenced this pull request May 7, 2024
With the switch to ActiveModel::Error in Rails 6.1, the hash-based access can be retired.

Reference:
rails/rails#32313
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet