Skip to content

Commit 3ac8118

Browse files
committed
Merge pull request #16056 from sgrif/sg-required-associations
Add a `required` option to singular associations
2 parents a6cc7b0 + 00f5551 commit 3ac8118

File tree

4 files changed

+105
-1
lines changed

4 files changed

+105
-1
lines changed

activerecord/lib/active_record/associations.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,10 @@ def has_many(name, scope = nil, options = {}, &extension)
13091309
# that is the inverse of this <tt>has_one</tt> association. Does not work in combination
13101310
# with <tt>:through</tt> or <tt>:as</tt> options.
13111311
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail.
1312+
# [:required]
1313+
# When set to +true+, the association will also have its presence validated.
1314+
# This will validate the association itself, not the id. You can use
1315+
# +:inverse_of+ to avoid an extra query during validation.
13121316
#
13131317
# Option examples:
13141318
# has_one :credit_card, dependent: :destroy # destroys the associated credit card
@@ -1320,6 +1324,7 @@ def has_many(name, scope = nil, options = {}, &extension)
13201324
# has_one :boss, readonly: :true
13211325
# has_one :club, through: :membership
13221326
# has_one :primary_address, -> { where primary: true }, through: :addressables, source: :addressable
1327+
# has_one :credit_card, required: true
13231328
def has_one(name, scope = nil, options = {})
13241329
reflection = Builder::HasOne.build(self, name, scope, options)
13251330
Reflection.add_reflection self, name, reflection
@@ -1421,6 +1426,10 @@ def has_one(name, scope = nil, options = {})
14211426
# object that is the inverse of this <tt>belongs_to</tt> association. Does not work in
14221427
# combination with the <tt>:polymorphic</tt> options.
14231428
# See ActiveRecord::Associations::ClassMethods's overview on Bi-directional associations for more detail.
1429+
# [:required]
1430+
# When set to +true+, the association will also have its presence validated.
1431+
# This will validate the association itself, not the id. You can use
1432+
# +:inverse_of+ to avoid an extra query during validation.
14241433
#
14251434
# Option examples:
14261435
# belongs_to :firm, foreign_key: "client_of"
@@ -1433,6 +1442,7 @@ def has_one(name, scope = nil, options = {})
14331442
# belongs_to :post, counter_cache: true
14341443
# belongs_to :company, touch: true
14351444
# belongs_to :company, touch: :employees_last_updated_at
1445+
# belongs_to :company, required: true
14361446
def belongs_to(name, scope = nil, options = {})
14371447
reflection = Builder::BelongsTo.build(self, name, scope, options)
14381448
Reflection.add_reflection self, name, reflection

activerecord/lib/active_record/associations/builder/association.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def self.build(model, name, scope, options, &block)
3636
reflection = builder.build(model)
3737
define_accessors model, reflection
3838
define_callbacks model, reflection
39+
define_validations model, reflection
3940
builder.define_extensions model
4041
reflection
4142
end
@@ -124,6 +125,10 @@ def #{name}=(value)
124125
CODE
125126
end
126127

128+
def self.define_validations(model, reflection)
129+
# noop
130+
end
131+
127132
def self.valid_dependent_options
128133
raise NotImplementedError
129134
end

activerecord/lib/active_record/associations/builder/singular_association.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module ActiveRecord::Associations::Builder
44
class SingularAssociation < Association #:nodoc:
55
def valid_options
6-
super + [:remote, :dependent, :primary_key, :inverse_of]
6+
super + [:remote, :dependent, :primary_key, :inverse_of, :required]
77
end
88

99
def self.define_accessors(model, reflection)
@@ -27,5 +27,12 @@ def create_#{name}!(*args, &block)
2727
end
2828
CODE
2929
end
30+
31+
def self.define_validations(model, reflection)
32+
super
33+
if reflection.options[:required]
34+
model.validates_presence_of reflection.name
35+
end
36+
end
3037
end
3138
end
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
require "cases/helper"
2+
3+
class RequiredAssociationsTest < ActiveRecord::TestCase
4+
self.use_transactional_fixtures = false
5+
6+
class Parent < ActiveRecord::Base
7+
end
8+
9+
class Child < ActiveRecord::Base
10+
end
11+
12+
setup do
13+
@connection = ActiveRecord::Base.connection
14+
@connection.create_table :parents, force: true
15+
@connection.create_table :children, force: true do |t|
16+
t.belongs_to :parent
17+
end
18+
end
19+
20+
teardown do
21+
@connection.execute("DROP TABLE IF EXISTS parents")
22+
@connection.execute("DROP TABLE IF EXISTS children")
23+
end
24+
25+
test "belongs_to associations are not required by default" do
26+
model = subclass_of(Child) do
27+
belongs_to :parent, inverse_of: false,
28+
class_name: "RequiredAssociationsTest::Parent"
29+
end
30+
31+
assert model.new.save
32+
assert model.new(parent: Parent.new).save
33+
end
34+
35+
test "required belongs_to associations have presence validated" do
36+
model = subclass_of(Child) do
37+
belongs_to :parent, required: true, inverse_of: false,
38+
class_name: "RequiredAssociationsTest::Parent"
39+
end
40+
41+
record = model.new
42+
assert_not record.save
43+
assert_equal ["Parent can't be blank"], record.errors.full_messages
44+
45+
record.parent = Parent.new
46+
assert record.save
47+
end
48+
49+
test "has_one associations are not required by default" do
50+
model = subclass_of(Parent) do
51+
has_one :child, inverse_of: false,
52+
class_name: "RequiredAssociationsTest::Child"
53+
end
54+
55+
assert model.new.save
56+
assert model.new(child: Child.new).save
57+
end
58+
59+
test "required has_one associations have presence validated" do
60+
model = subclass_of(Parent) do
61+
has_one :child, required: true, inverse_of: false,
62+
class_name: "RequiredAssociationsTest::Child"
63+
end
64+
65+
record = model.new
66+
assert_not record.save
67+
assert_equal ["Child can't be blank"], record.errors.full_messages
68+
69+
record.child = Child.new
70+
assert record.save
71+
end
72+
73+
private
74+
75+
def subclass_of(klass, &block)
76+
subclass = Class.new(klass, &block)
77+
def subclass.name
78+
superclass.name
79+
end
80+
subclass
81+
end
82+
end

0 commit comments

Comments
 (0)