Évaluons votre projet

Indexer les erreurs sur les sous-objets

Publié le 22 octobre 2021 par Hugo Fabre | rails

Cet article est publié sous licence CC BY-NC-SA

Lors du développement d’un projet Rails en mode API uniquement, avec un ou plusieurs clients, il y a quelque chose qui n’est pas forcément facile à gérer, ce sont les erreurs. Aujourd’hui on va se pencher sur un type d’erreur assez spécifique, c’est lorsque vous sauvegardez un objet avec des sous-objets qui peuvent eux aussi être invalides. Prenons un exemple :

# models/order.rb
class Order < ApplicationRecord
  has_many :order_lines
end

# models/order_line.rb
class OrderLine < ApplicationRecord
  belongs_to :order

  validates :quantity, numericality: { greater_than: 0 }
  validates :product, presence: true
end

C’est tout ce dont nous avons besoin en termes de code, le reste peut se faire dans une console Rails. Commençons par une situation où tout se passe bien :

order_lines = [OrderLine.new(quantity: 1, product: "controller"), OrderLine.new(quantity: 3, product: "mouse")]
# => [#<OrderLine id: nil, product: "controller", quantity: 1, order_id: nil, created_at: nil, updated_at: nil>, #<OrderLine id: nil, product: "mouse", quantity: 3, order_id: nil, created_at: nil, updated_at: nil>]

o = Order.new
# => #<Order id: nil, created_at: nil, updated_at: nil>

o.order_lines = order_lines
# => [#<OrderLine id: nil, product: "controller", quantity: 1, order_id: nil, created_at: nil, updated_at: nil>, #<OrderLine id: nil, product: "mouse", quantity: 3, order_id: nil, created_at: nil, updated_at: nil>]

o.save
# => true

o.errors
# => #<ActiveModel::Errors:0x00007f97a15cea78 @base=#<Order id: 1, created_at: "2021-09-10 13:00:54.310420000 +0000", updated_at: "2021-09-10 13:00:54.310420000 +0000">, @errors=[]>

C’est normal, pas d’erreur à déclarer ici. Passons à un cas en erreur

order_lines = [OrderLine.new(quantity: -2, product: "controller"), OrderLine.new(quantity: 3, product: "mouse")]
# => [#<OrderLine id: nil, product: "controller", quantity: -2, order_id: nil, created_at: nil, updated_at: nil>, #<OrderLine id: nil, product: "mouse", quantity: 3, order_id: nil, created_at: nil, updated_at: nil>]

o = Order.new
# => #<Order id: nil, created_at: nil, updated_at: nil>

o.order_lines = order_lines
# => [#<OrderLine id: nil, product: "controller", quantity: -2, order_id: nil, created_at: nil, updated_at: nil>, #<OrderLine id: nil, product: "mouse", quantity: 3, order_id: nil, created_at: nil, updated_at: nil>]

o.save
# => false

o.errors
# => #<ActiveModel::Errors:0x00007f97a1d04ab8 @base=#<Order id: nil, created_at: nil, updated_at: nil>, @errors=[#<ActiveModel::Error attribute=order_lines, type=invalid, options={}>]>

o.errors.messages
# => {:order_lines=>["is invalid"]}

Pour expliciter un peu les erreurs des sous-modèles on peut passer par l’utilisation de accepts_nested_attributes_for

On le rajoute dans notre modèle

# models/order.rb
class Order < ApplicationRecord
  has_many :order_lines
  accepts_nested_attributes_for :order_lines
end
o = Order.new
# => #<Order id: nil, created_at: nil, updated_at: nil>

o.order_lines = order_lines
# => [#<OrderLine id: nil, product: "controller", quantity: -2, order_id: nil, created_at: nil, updated_at: nil>, #<OrderLine id: nil, product: nil, quantity: -3, order_id: nil, created_at: nil, updated_at: nil>]

o.save
# => false

o.errors
# => #<ActiveModel::Errors:0x00007f979e71cd88 @base=#<Order id: nil, created_at: nil, updated_at: nil>, @errors=[#<ActiveModel::NestedError attribute=order_lines.quantity, type=greater_than, options={:value=>-2, :count=>0}>, #<ActiveModel::NestedError attribute=order_lines.quantity, type=greater_than, options={:value=>-3, :count=>0}>, #<ActiveModel::NestedError attribute=order_lines.product, type=blank, options={}>]>

o.errors.messages
# => {:"order_lines.quantity"=>["must be greater than 0", "must be greater than 0"], :"order_lines.product"=>["can't be blank"]}

C’est déjà mieux, mais comment savoir quelle erreur est assignée à quel sous-objet ? Et bien depuis sa version 5 Rails fourni une option sur has_many : index_errors, et cette option qui porte plutôt bien son nom sert à indexer les erreurs sur une relation has_many. Nous changeons donc notre modèle Order:

# models/order.rb
class Order < ApplicationRecord
  has_many :order_lines, index_errors: true # On note la nouvelle option
  accepts_nested_attributes_for :order_lines
end
order_lines = [OrderLine.new(quantity: -2, product: "controller"), OrderLine.new(quantity: -3, product: nil)]
# => [#<OrderLine id: nil, product: "controller", quantity: -2, order_id: nil, created_at: nil, updated_at: nil>, #<OrderLine id: nil, product: nil, quantity: -3, order_id: nil, created_at: nil, updated_at: nil>]

o = Order.new
# => #<Order id: nil, created_at: nil, updated_at: nil>

o.order_lines = order_lines
# => [#<OrderLine id: nil, product: "controller", quantity: -2, order_id: nil, created_at: nil, updated_at: nil>, #<OrderLine id: nil, product: nil, quantity: -3, order_id: nil, created_at: nil, updated_at: nil>]

o.save
# => false

o.errors
# => #<ActiveModel::Errors:0x00007f97a125af40 @base=#<Order id: nil, created_at: nil, updated_at: nil>, @errors=[#<ActiveModel::NestedError attribute=order_lines[0].quantity, type=greater_than, options={:value=>-2, :count=>0}>, #<ActiveModel::NestedError attribute=order_lines[1].quantity, type=greater_than, options={:value=>-3, :count=>0}>, #<ActiveModel::NestedError attribute=order_lines[1].product, type=blank, options={}>]>

o.errors.messages
# => {:"order_lines[0].quantity"=>["must be greater than 0"], :"order_lines[1].quantity"=>["must be greater than 0"], :"order_lines[1].product"=>["can't be blank"]}

Et là, en plus d’être expressif, notre client pourra sans problème assigner les messages d’erreur à la bonne ligne.

Je vous laisse avec l’article (en anglais) qui m’a fait découvrir cette option, celle-ci n’étant aujourd’hui pas documentée.


L’équipe Synbioz.
Libres d’être ensemble.