Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ActiveModel::Errors and AdequateErrors

ActiveModel::Errors and AdequateErrors

Solving and refactoring Rails' ActiveModel::Errors interface.

Avatar for lulalala

lulalala

April 27, 2018
Tweet

More Decks by lulalala

Other Decks in Technology

Transcript

  1. About me lulalala otaku and happy rubyist Open speaker in

    (un)conference twitter: @lulalala_it @lulalala_it 2
  2. What's out of the scope? ⌧ Ruby Exception and Error

    Handling ⌧ Revamping ActiveModel Validation ⌧ Non-Rails model validation errors (e.g. Dry- validation) @lulalala_it 3
  3. Simple Interface Just arrays and hashes and strings Simple to

    use ≠ easy to accomplish something Over the years I have encountered many issues with the interface. @lulalala_it 9
  4. I usually use workaround those, but one day, PM gave

    us this requirement: @lulalala_it 10
  5. details.map { |detail| type = detail.delete(:error) next if type ==

    :too_coooool if type.is_a?(Symbol) model.errors.full_message( attribute, model.errors.generate_message(attribute, type, detail) ) else model.errors.full_message( attribute, type ) end }.compact @lulalala_it 12
  6. And I realized that one architecture decision is the root

    of a lot of those issues. @lulalala_it 16
  7. Active Model Errors provides a modified Hash that you can

    include in your object for handling error messages... Rails API doc @lulalala_it 17
  8. Active Model Errors represents an array of Error objects that

    you can include in your object for handling error messages... lulalala @lulalala_it 18
  9. Current Errors: » @messages - A hash of array of

    Strings » @details - A hash of array of hashes My ideal Errors: » @errors - An array of Error objects @lulalala_it 19
  10. Error object » Represents one error e = Error.new(:title, :too_coooool,

    count: 432) e.attribute # :title e.type # :too_coooool e.options # {count: 432} e.message # "is too coooool for ya" e.full_message # "Title is too coooool for ya" @lulalala_it 20
  11. Information is not all over the place. Easier to get

    all data of one error @lulalala_it 22
  12. Message and corresponding details {:name => [ {error: :foo_error, count:

    1}, {error: :bar_error}, {error: :foo_error, count: 3} ]} How to access the message of the last error? model.errors.messages[:name][2] @lulalala_it 26
  13. Yes, some message may have no details An equivalent* to

    errors#add is to use << to append a message to the errors.messages array for an attribute: - Rails Guide errors.messages[:name] << "foo" * obviously not accurate @lulalala_it 29
  14. Rails does weird things that you do not know of

    For example, it calls uniq! when importing errors from associations errors[attribute].uniq! errors.details[reflection_attribute].uniq! @lulalala_it 30
  15. Finer granularity: delete Do you know why delete can only

    be done on attribute level? model.errors.delete(:title) @lulalala_it 31
  16. Finer granularity: delete » message:details are not 1:1 » can't

    reliably pinpoint target data. With Error object, we can achieve this: model.errors.delete(attribute: :title, type: :too_cooool) @lulalala_it 32
  17. Data in many places causes Data inconsistency (not 1:1) and

    with Data in one place, we would have less issue. @lulalala_it 33
  18. Easier Enumeration model. errors. find_all{|e| e.options[:count] < 500}. map(&:message) Much

    easier to enumerate one array, instead of hashes of arrays. @lulalala_it 35
  19. Extensible by gem authors A class for you to monkey

    patch and add functionalities. @lulalala_it 36
  20. Benefits of Error object 1. Information in one place 2.

    Eliminate old issue of message:details not 1:1 3. Easier enumeration 4. Extensible @lulalala_it 37
  21. Lazy message evaluation @morgoth mentioned that it would be desirable

    to evaluate message lazily. Currently: # default locale being :en I18n.with_locale(:pl) { user.error.full_messages } # => still in EN Desired behavior: I18n.with_locale(:pl) { user.error.full_messages } # => outputs PL errors I18n.with_locale(:pt) { user.error.full_messages } # => outputs PT errors @lulalala_it 47
  22. Finer granularity: where query A convenience method to do filtering

    model.errors.where(type: :too_cool) # returns an array of all too_cool type errors @lulalala_it 48
  23. Sometimes we want to copy errors across models » Validating

    child association using autosave association » Form objects and service objects @lulalala_it 49
  24. Autosave Association class School < ActiveRecord::Base has_many :students, autosave: true

    When saving school, if student autosave fails, its errors are copied to School. {:"students.name"=>["can't be blank"]} @lulalala_it 50
  25. Autosave Association - how is it done? item.errors.add(:moo, :invalid) NoMethodError:

    undefined method `moo' for #<Item id: nil, name: nil, created_at: nil, updated_at: nil> Reason: generating message requires reading the attribute value. So how is "students.name" error added to School? @lulalala_it 51
  26. Autosave Association - bypass add record.errors.each do |attribute, message| errors["#{reflection.name}.#{attribute}"]

    << message errors[attribute].uniq! ... record.errors.details[attribute].each do |error| errors.details[reflection_attribute] << error errors.details[reflection_attribute].uniq! @lulalala_it 52
  27. Autosave Association and Error object How can we do it

    with Error objects? » Can we copy the Error object and append it to parent? » Message rendering would break, because parent does not have that attribute. » Introducing NestedError @lulalala_it 53
  28. NestedError class NestedError < Error It is a wrapper for

    source error. Methods such as message and full_message are forwarded to source error. @lulalala_it 54
  29. import errors It's like add, but takes in an Error

    and wraps it as NestedError def import(error, override_options = {}) @errors.append( NestedError.new(@base, error, override_options) ) end @lulalala_it 55
  30. Flexibility with full message attribute prefix validates_acceptance_of :terms_of_service, message: 'Please

    accept the terms of service' We get: "Terms of Service Please accept the terms of service" @lulalala_it 56
  31. Can we assign it to :base ? errors.add(:base, :foo) But

    we can't here because we are using default validator. @lulalala_it 57
  32. Can we monkey-patch it? custom_error_message gem detects if message starts

    with ^, and will skip prefix if it is. validates_acceptance_of :terms_of_service, message: '^Please accept the terms of service' But this can have edge case bug too. @lulalala_it 58
  33. Solution: full message only error.message always display full message. messages:

    accepted: "must be accepted" ...is changed to: messages: accepted: "%{attribute} must be accepted" @lulalala_it 59
  34. AdequateErrors was done. I am happy, so I took the

    logical next step. @lulalala_it 61
  35. Break as little things as possible Feature Adequate Errors Pull

    Request Error object Yes Yes Lazy message evaluation Yes Yes Full message always Yes Nested Error Yes Yes where query Yes Slightly different @lulalala_it 65
  36. Each I want errors to enumerate like an array: model.errors.each

    { |error| } But we also want to keep existing functionality: model.errors.each { |attribute, message| } @lulalala_it 66
  37. Each - check arity def each(&block) if block.arity == 1

    @errors.each(&block) else raise_depreacation_warning @errors.each { |error| yield error.attribute, error.message } end end @lulalala_it 67
  38. Each and Enumerable class Errors include Enumerable 40+ methods (e.g.

    reject, map, uniq) are available through each. @lulalala_it 68
  39. Our each is not very fast, therefore enumerable methods are

    not very fast. We can speed up some methods by forwarding it to Error array directly. @lulalala_it 69
  40. Each - ordering message and details are attribute-keyed hashes: each

    iteration are kind of sorted (grouped) by attribute. @errors. sort { |a, b| a.attribute <=> b.attribute }. each { |error| yield error.attribute, error.message } @lulalala_it 70
  41. Finer granularity: added? errors.add(:title, :too_short, count: 3) errors.added?(:title, :too_short, count:

    3) # true # Rails 5.2: errors.added?(:title, :too_short) # false # PR: errors.added?(:title, :too_short) # true @lulalala_it 71
  42. Do you know hash and array can have default value

    by using Proc? h = Hash.new { |h, key| h[key] = [] } h[404] h # {404=>[]} @lulalala_it 73
  43. Rails uses this so you can append hash dynamically errors[:title]

    << 'bar' But this cause it to be unserializable @lulalala_it 74
  44. Serialization - Marshal So in Rails 5, custom marshal is

    defined to remove and add default proc. def marshal_dump # :nodoc: [@base, without_default_proc(@messages), without_default_proc(@details)] end def marshal_load(array) # :nodoc: @base, @messages, @details = array apply_default_array(@messages) apply_default_array(@details) end @lulalala_it 75
  45. Serialization - Marshal In my PR, we no longer allows

    errors[:title] << 'bar' We can remove complexities related to procs and custom marshaling. @lulalala_it 76
  46. Serialization Support loading dump across Rails 5 and 6 We

    add marshal_dump and marshal_load to customize it. But what will happen if we remove those? @lulalala_it 78
  47. Bonus Serialization - YAML » Guess how YAML serialization can

    be customized? » yaml_load? » Wrong! init_with » Totally different interfaces (Rantings: could the interface for Marshal and YAML be unified?) @lulalala_it 84
  48. Deprecation » each and enumerable friends (when used as hash)

    » values, keys » to_xml (a very very old method) » full_message » generate_message » [] (use message_for instead) @lulalala_it 85
  49. Anyways, a lot of things needs to be fixed in

    the PR. » dup, deep_dup and clone » errors.add(:title, -> { Time.now } ) » merge Just takes some time to fix one by one... @lulalala_it 86
  50. Let's return to the very first example errors.details.map { |detail|

    type = detail.delete(:error) next if type == :too_coooool if type.is_a?(Symbol) model.errors.full_message( attribute, model.errors.generate_message(attribute, type, detail) ) else model.errors.full_message( attribute, type ) end }.compact @lulalala_it 88