Skip to content

Commit 0a36c36

Browse files
committed
Add index_errors: :nested_attributes_order mode
which respects reject_if and is in nested_attributes order. When in default index_errors:true mode, fix rails#24390 and return index based on full association order.
1 parent 25f2250 commit 0a36c36

File tree

8 files changed

+224
-26
lines changed

8 files changed

+224
-26
lines changed

activerecord/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* Fix `index_errors` having incorrect index in association validation errors.
2+
3+
*lulalala*
4+
5+
* Add `index_errors: :nested_attributes_order` mode.
6+
7+
This indexes the association validation errors based on the order received by nested attributes setter, and respects the `reject_if` configuration. This enables API to provide enough information to the frontend to map the validation errors back to their respective form fields.
8+
9+
*lulalala*
10+
111
* Association option `query_constraints` is deprecated in favor of `foreign_key`.
212

313
*Nikita Vasilevsky*

activerecord/lib/active_record/associations.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,13 @@ module ClassMethods
15061506
# Serves as a composite foreign key. Defines the list of columns to be used to query the associated object.
15071507
# This is an optional option. By default Rails will attempt to derive the value automatically.
15081508
# When the value is set the Array size must match associated model's primary key or +query_constraints+ size.
1509+
# [:index_errors]
1510+
# Allows differentiation of multiple validation errors from the association records, by including
1511+
# an index in the error attribute name, e.g. `roles[2].level`.
1512+
# When set to +true+, the index is based on association order, i.e. database order, with yet to be
1513+
# persisted new records placed at the end.
1514+
# When set to +:nested_attributes_order+, the index is based on the record order received by
1515+
# nested attributes setter, when accepts_nested_attributes_for is used.
15091516
#
15101517
# Option examples:
15111518
# has_many :comments, -> { order("posted_on") }
@@ -1519,6 +1526,7 @@ module ClassMethods
15191526
# has_many :subscribers, through: :subscriptions, disable_joins: true
15201527
# has_many :comments, strict_loading: true
15211528
# has_many :comments, query_constraints: [:blog_id, :post_id]
1529+
# has_many :comments, index_errors: :nested_attributes_order
15221530
def has_many(name, scope = nil, **options, &extension)
15231531
reflection = Builder::HasMany.build(self, name, scope, options, &extension)
15241532
Reflection.add_reflection self, name, reflection

activerecord/lib/active_record/associations/collection_association.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ module Associations
2828
# If you need to work on all current children, new and existing records,
2929
# +load_target+ and the +loaded+ flag are your friends.
3030
class CollectionAssociation < Association # :nodoc:
31+
attr_accessor :nested_attributes_target
32+
3133
# Implements the reader method, e.g. foo.items for Foo.has_many :items
3234
def reader
3335
ensure_klass_exists!
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
# Validation error class to wrap association records' errors,
4+
# with index_errors support.
5+
module ActiveRecord
6+
module Associations
7+
class NestedError < ::ActiveModel::NestedError
8+
def initialize(association, inner_error)
9+
@base = association.owner
10+
@association = association
11+
@inner_error = inner_error
12+
super(@base, inner_error, { attribute: compute_attribute(inner_error) })
13+
end
14+
15+
private
16+
attr_reader :association
17+
18+
def compute_attribute(inner_error)
19+
association_name = association.reflection.name
20+
21+
attribute =
22+
if index_errors_setting && index
23+
"#{association_name}[#{index}]"
24+
else
25+
association_name
26+
end
27+
28+
attribute = :"#{attribute}.#{inner_error.attribute}" if inner_error.attribute != :base
29+
attribute.to_sym
30+
end
31+
32+
def index_errors_setting
33+
@index_errors_setting ||=
34+
association.options.fetch(:index_errors, ActiveRecord.index_nested_attribute_errors)
35+
end
36+
37+
def index
38+
@index ||= ordered_records&.find_index(inner_error.base)
39+
end
40+
41+
def ordered_records
42+
case index_errors_setting
43+
when true # default is association order
44+
association.target
45+
when :nested_attributes_order
46+
association.nested_attributes_target
47+
end
48+
end
49+
end
50+
end
51+
end

activerecord/lib/active_record/autosave_association.rb

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "active_record/associations/nested_error"
4+
35
module ActiveRecord
46
# = Active Record Autosave Association
57
#
@@ -315,7 +317,7 @@ def nested_records_changed_for_autosave?
315317
def validate_single_association(reflection)
316318
association = association_instance_get(reflection.name)
317319
record = association && association.reader
318-
association_valid?(reflection, record) if record && (record.changed_for_autosave? || custom_validation_context?)
320+
association_valid?(association, record) if record && (record.changed_for_autosave? || custom_validation_context?)
319321
end
320322

321323
# Validate the associated records if <tt>:validate</tt> or
@@ -324,48 +326,33 @@ def validate_single_association(reflection)
324326
def validate_collection_association(reflection)
325327
if association = association_instance_get(reflection.name)
326328
if records = associated_records_to_validate_or_save(association, new_record?, reflection.options[:autosave])
327-
records.each_with_index { |record, index| association_valid?(reflection, record, index) }
329+
records.each { |record| association_valid?(association, record) }
328330
end
329331
end
330332
end
331333

332334
# Returns whether or not the association is valid and applies any errors to
333335
# the parent, <tt>self</tt>, if it wasn't. Skips any <tt>:autosave</tt>
334336
# enabled records if they're marked_for_destruction? or destroyed.
335-
def association_valid?(reflection, record, index = nil)
336-
return true if record.destroyed? || (reflection.options[:autosave] && record.marked_for_destruction?)
337+
def association_valid?(association, record)
338+
return true if record.destroyed? || (association.options[:autosave] && record.marked_for_destruction?)
337339

338340
context = validation_context if custom_validation_context?
339341

340342
unless valid = record.valid?(context)
341-
if reflection.options[:autosave]
342-
indexed_attribute = !index.nil? && (reflection.options[:index_errors] || ActiveRecord.index_nested_attribute_errors)
343-
344-
record.errors.group_by_attribute.each { |attribute, errors|
345-
attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
346-
347-
errors.each { |error|
348-
self.errors.import(
349-
error,
350-
attribute: attribute
351-
)
352-
}
343+
if association.options[:autosave]
344+
record.errors.each { |error|
345+
self.errors.objects.append(
346+
Associations::NestedError.new(association, error)
347+
)
353348
}
354349
else
355-
errors.add(reflection.name)
350+
errors.add(association.reflection.name)
356351
end
357352
end
358353
valid
359354
end
360355

361-
def normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
362-
if indexed_attribute
363-
"#{reflection.name}[#{index}].#{attribute}"
364-
else
365-
"#{reflection.name}.#{attribute}"
366-
end
367-
end
368-
369356
# Is used as an around_save callback to check while saving a collection
370357
# association whether or not the parent was a new record before saving.
371358
def around_save_collection_association

activerecord/lib/active_record/nested_attributes.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ def assign_nested_attributes_for_collection_association(association_name, attrib
514514
attribute_ids.empty? ? [] : association.scope.where(association.klass.primary_key => attribute_ids)
515515
end
516516

517-
attributes_collection.each do |attributes|
517+
records = attributes_collection.map do |attributes|
518518
if attributes.respond_to?(:permitted?)
519519
attributes = attributes.to_h
520520
end
@@ -537,11 +537,14 @@ def assign_nested_attributes_for_collection_association(association_name, attrib
537537
end
538538

539539
assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
540+
existing_record
540541
end
541542
else
542543
raise_nested_attributes_record_not_found!(association_name, attributes["id"])
543544
end
544545
end
546+
547+
association.nested_attributes_target = records
545548
end
546549

547550
# Takes in a limit and checks if the attributes_collection has too many
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/guitar"
5+
require "models/tuning_peg"
6+
7+
class AssociationsNestedErrorInAssociationOrderTest < ActiveRecord::TestCase
8+
test "index in association order" do
9+
guitar = Guitar.create!
10+
guitar.tuning_pegs.create!(pitch: 1)
11+
peg2 = guitar.tuning_pegs.create!(pitch: 2)
12+
peg2.pitch = nil
13+
guitar.valid?
14+
15+
error = guitar.errors.objects.first
16+
17+
assert_equal ActiveRecord::Associations::NestedError, error.class
18+
assert_equal peg2.errors.objects.first, error.inner_error
19+
assert_equal :'tuning_pegs[1].pitch', error.attribute
20+
assert_equal :not_a_number, error.type
21+
assert_equal "is not a number", error.message
22+
assert_equal guitar, error.base
23+
end
24+
end
25+
26+
class AssociationsNestedErrorInNestedAttributesOrderTest < ActiveRecord::TestCase
27+
def setup
28+
tuning_peg_class = Class.new(ActiveRecord::Base) do
29+
self.table_name = "tuning_pegs"
30+
def self.name; "TuningPeg"; end
31+
32+
validates_numericality_of :pitch
33+
end
34+
35+
@guitar_class = Class.new(ActiveRecord::Base) do
36+
has_many :tuning_pegs, index_errors: :nested_attributes_order, anonymous_class: tuning_peg_class
37+
accepts_nested_attributes_for :tuning_pegs, reject_if: lambda { |attrs| attrs[:pitch]&.odd? }
38+
39+
def self.name; "Guitar"; end
40+
end
41+
end
42+
43+
test "index in nested attributes order" do
44+
guitar = @guitar_class.create!
45+
guitar.tuning_pegs.create!(pitch: 1)
46+
peg2 = guitar.tuning_pegs.create!(pitch: 2)
47+
guitar.update(tuning_pegs_attributes: [{ id: peg2.id, pitch: nil }])
48+
49+
error = guitar.errors.objects.first
50+
51+
assert_equal ActiveRecord::Associations::NestedError, error.class
52+
assert_equal peg2.errors.objects.first, error.inner_error
53+
assert_equal :'tuning_pegs[0].pitch', error.attribute
54+
assert_equal :not_a_number, error.type
55+
assert_equal "is not a number", error.message
56+
assert_equal guitar, error.base
57+
end
58+
59+
test "index unaffected by reject_if" do
60+
guitar = @guitar_class.create!
61+
62+
guitar.update(
63+
tuning_pegs_attributes: [
64+
{ pitch: 1 }, # rejected
65+
{ pitch: nil },
66+
]
67+
)
68+
69+
error = guitar.errors.objects.first
70+
71+
assert_equal ActiveRecord::Associations::NestedError, error.class
72+
assert_equal :'tuning_pegs[1].pitch', error.attribute
73+
assert_equal :not_a_number, error.type
74+
assert_equal "is not a number", error.message
75+
assert_equal guitar, error.base
76+
end
77+
end

activerecord/test/cases/nested_attributes_test.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,21 @@ def test_numeric_column_changes_from_zero_to_no_empty_string
868868
end
869869
end
870870

871+
def test_assigning_nested_attributes_target
872+
params = @alternate_params[association_getter].values
873+
@pirate.public_send(association_setter, params)
874+
@pirate.save
875+
assert_equal [@child_1, @child_2], @pirate.association(@association_name).nested_attributes_target
876+
end
877+
878+
def test_assigning_nested_attributes_target_with_nil_placeholder_for_rejected_item
879+
params = @alternate_params[association_getter].values
880+
params.insert(1, {})
881+
@pirate.public_send(association_setter, params)
882+
@pirate.save
883+
assert_equal [@child_1, nil, @child_2], @pirate.association(@association_name).nested_attributes_target
884+
end
885+
871886
private
872887
def association_setter
873888
@association_setter ||= "#{@association_name}_attributes=".to_sym
@@ -1124,6 +1139,51 @@ def setup
11241139
end
11251140
end
11261141

1142+
class TestIndexErrorsWithNestedAttributesOnlyMode < ActiveRecord::TestCase
1143+
def setup
1144+
tuning_peg_class = Class.new(ActiveRecord::Base) do
1145+
self.table_name = "tuning_pegs"
1146+
def self.name; "TuningPeg"; end
1147+
1148+
validates_numericality_of :pitch
1149+
end
1150+
1151+
@guitar_class = Class.new(ActiveRecord::Base) do
1152+
has_many :tuning_pegs, index_errors: :nested_attributes_order, anonymous_class: tuning_peg_class
1153+
accepts_nested_attributes_for :tuning_pegs, reject_if: lambda { |attrs| attrs[:pitch]&.odd? }
1154+
1155+
def self.name; "Guitar"; end
1156+
end
1157+
end
1158+
1159+
test "index in nested_attributes_order order" do
1160+
guitar = @guitar_class.create!
1161+
guitar.tuning_pegs.create!(pitch: 1)
1162+
peg2 = guitar.tuning_pegs.create!(pitch: 2)
1163+
1164+
assert_predicate guitar, :valid?
1165+
1166+
guitar.update(tuning_pegs_attributes: [{ id: peg2.id, pitch: nil }])
1167+
1168+
assert_not_predicate guitar, :valid?
1169+
assert_equal [:"tuning_pegs[0].pitch"], guitar.errors.messages.keys
1170+
end
1171+
1172+
test "index unaffected by reject_if" do
1173+
guitar = @guitar_class.create!
1174+
1175+
guitar.update(
1176+
tuning_pegs_attributes: [
1177+
{ pitch: 1 },
1178+
{ pitch: nil },
1179+
]
1180+
)
1181+
1182+
assert_not_predicate guitar, :valid?
1183+
assert_equal [:"tuning_pegs[1].pitch"], guitar.errors.messages.keys
1184+
end
1185+
end
1186+
11271187
class TestNestedAttributesWithExtend < ActiveRecord::TestCase
11281188
setup do
11291189
Pirate.accepts_nested_attributes_for :treasures

0 commit comments

Comments
 (0)