Skip to content

Commit 7b6720d

Browse files
Raise on assignment to readonly attributes
Previously, assignment would succeed but silently not write to the database. The changes to counter_cache are necessary because incrementing the counter cache for a column calls []=. I investigated an approach to use _write_attribute instead, however counter caches are expected to resolve attribute aliases so write_attribute/[]= seems more correct. Similarly, []= was replaced with _write_attribute in merge_target_lists to skip the overriden []= and the primary key check. attribute_names will already return custom primary keys so the primary_key check in write_attribute is not needed. Co-authored-by: Alex Ghiculescu <[email protected]>
1 parent 4da49fa commit 7b6720d

File tree

14 files changed

+216
-7
lines changed

14 files changed

+216
-7
lines changed

activerecord/CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
* Raise on assignment to readonly attributes
2+
3+
```ruby
4+
class Post < ActiveRecord::Base
5+
attr_readonly :content
6+
end
7+
Post.create!(content: "cannot be updated")
8+
post.content # "cannot be updated"
9+
post.content = "something else" # => ActiveRecord::ReadonlyAttributeError
10+
```
11+
12+
Previously, assignment would succeed but silently not write to the database.
13+
14+
This behavior can be controlled by configuration:
15+
16+
```ruby
17+
config.active_record.raise_on_assign_to_attr_readonly = true
18+
```
19+
20+
and will be enabled by default with `load_defaults 7.1`
21+
22+
*Alex Ghiculescu*, *Hartley McGuire*
23+
124
* Allow unscoping of preload and eager_load associations
225

326
Added the ability to unscope preload and eager_load associations just like

activerecord/lib/active_record.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ def self.global_executor_concurrency # :nodoc:
269269
singleton_class.attr_accessor :maintain_test_schema
270270
self.maintain_test_schema = nil
271271

272+
singleton_class.attr_accessor :raise_on_assign_to_attr_readonly
273+
self.raise_on_assign_to_attr_readonly = false
274+
272275
##
273276
# :singleton-method:
274277
# Specify a threshold for the size of query result sets. If the number of

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def self.add_counter_cache_callbacks(model, reflection)
3737
}
3838

3939
klass = reflection.class_name.safe_constantize
40-
klass.attr_readonly cache_column if klass && klass.respond_to?(:attr_readonly)
40+
klass._counter_cache_columns << cache_column if klass && klass.respond_to?(:_counter_cache_columns)
4141
end
4242

4343
def self.touch_record(o, changes, foreign_key, name, touch, touch_method) # :nodoc:

activerecord/lib/active_record/associations/collection_association.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ def merge_target_lists(persisted, memory)
325325
if mem_record = memory.delete(record)
326326

327327
((record.attribute_names & mem_record.attribute_names) - mem_record.changed_attribute_names_to_save).each do |name|
328-
mem_record[name] = record[name]
328+
mem_record._write_attribute(name, record[name])
329329
end
330330

331331
mem_record

activerecord/lib/active_record/attribute_methods.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ def attributes_for_update(attribute_names)
396396
attribute_names &= self.class.column_names
397397
attribute_names.delete_if do |name|
398398
self.class.readonly_attribute?(name) ||
399+
self.class.counter_cache_column?(name) ||
399400
column_for_attribute(name).virtual?
400401
end
401402
end

activerecord/lib/active_record/counter_cache.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ module ActiveRecord
55
module CounterCache
66
extend ActiveSupport::Concern
77

8+
included do
9+
class_attribute :_counter_cache_columns, instance_accessor: false, default: []
10+
end
11+
812
module ClassMethods
913
# Resets one or more counter caches to their correct value using an SQL
1014
# count query. This is useful when adding new counter caches, or if the
@@ -162,6 +166,10 @@ def increment_counter(counter_name, id, touch: nil)
162166
def decrement_counter(counter_name, id, touch: nil)
163167
update_counters(id, counter_name => -1, touch: touch)
164168
end
169+
170+
def counter_cache_column?(name) # :nodoc:
171+
_counter_cache_columns.include?(name)
172+
end
165173
end
166174

167175
private

activerecord/lib/active_record/railtie.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class Railtie < Rails::Railtie # :nodoc:
3636
config.active_record.query_log_tags = [ :application ]
3737
config.active_record.query_log_tags_format = :legacy
3838
config.active_record.cache_query_log_tags = false
39+
config.active_record.raise_on_assign_to_attr_readonly = false
3940

4041
config.active_record.queues = ActiveSupport::InheritableOptions.new
4142

activerecord/lib/active_record/readonly_attributes.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# frozen_string_literal: true
22

33
module ActiveRecord
4+
class ReadonlyAttributeError < ActiveRecordError
5+
end
6+
47
module ReadonlyAttributes
58
extend ActiveSupport::Concern
69

@@ -23,7 +26,21 @@ module ClassMethods
2326
# post = Post.create!(title: "Introducing Ruby on Rails!")
2427
# post.update(title: "a different title") # change to title will be ignored
2528
def attr_readonly(*attributes)
26-
self._attr_readonly = Set.new(attributes.map(&:to_s)) + (_attr_readonly || [])
29+
new_attributes = attributes.map(&:to_s).reject { |a| _attr_readonly.include?(a) }
30+
31+
if ActiveRecord.raise_on_assign_to_attr_readonly
32+
new_attributes.each do |attribute|
33+
define_method("#{attribute}=") do |value|
34+
raise ReadonlyAttributeError.new(attribute) unless new_record?
35+
36+
super(value)
37+
end
38+
end
39+
40+
include(HasReadonlyAttributes)
41+
end
42+
43+
self._attr_readonly = Set.new(new_attributes) + _attr_readonly
2744
end
2845

2946
# Returns an array of all the attributes that have been specified as readonly.
@@ -35,5 +52,15 @@ def readonly_attribute?(name) # :nodoc:
3552
_attr_readonly.include?(name)
3653
end
3754
end
55+
56+
module HasReadonlyAttributes # :nodoc:
57+
def write_attribute(attr_name, value)
58+
if !new_record? && self.class.readonly_attribute?(attr_name.to_s)
59+
raise ReadonlyAttributeError.new(attr_name)
60+
end
61+
62+
super
63+
end
64+
end
3865
end
3966
end

activerecord/test/cases/base_test.rb

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ class ReadonlyTitlePost < Post
6666
attr_readonly :title
6767
end
6868

69+
previous_value, ActiveRecord.raise_on_assign_to_attr_readonly = ActiveRecord.raise_on_assign_to_attr_readonly, false
70+
71+
class NonRaisingPost < Post
72+
attr_readonly :title
73+
end
74+
75+
ActiveRecord.raise_on_assign_to_attr_readonly = previous_value
76+
6977
class Weird < ActiveRecord::Base; end
7078

7179
class LintTest < ActiveRecord::TestCase
@@ -691,16 +699,132 @@ def test_comparison_with_different_objects_in_array
691699
end
692700

693701
def test_readonly_attributes
694-
assert_equal Set.new([ "title", "comments_count" ]), ReadonlyTitlePost.readonly_attributes
702+
assert_equal Set.new([ "title" ]), ReadonlyTitlePost.readonly_attributes
695703

696704
post = ReadonlyTitlePost.create(title: "cannot change this", body: "changeable")
705+
assert_equal "cannot change this", post.title
706+
assert_equal "changeable", post.body
707+
708+
post = Post.find(post.id)
709+
assert_equal "cannot change this", post.title
710+
assert_equal "changeable", post.body
711+
712+
assert_raises(ActiveRecord::ReadonlyAttributeError) do
713+
post.title = "changed via assignment"
714+
end
715+
post.body = "changed via assignment"
716+
assert_equal "cannot change this", post.title
717+
assert_equal "changed via assignment", post.body
718+
719+
assert_raises(ActiveRecord::ReadonlyAttributeError) do
720+
post.write_attribute(:title, "changed via write_attribute")
721+
end
722+
post.write_attribute(:body, "changed via write_attribute")
723+
assert_equal "cannot change this", post.title
724+
assert_equal "changed via write_attribute", post.body
725+
726+
assert_raises(ActiveRecord::ReadonlyAttributeError) do
727+
post.assign_attributes(body: "changed via assign_attributes", title: "changed via assign_attributes")
728+
end
729+
assert_equal "cannot change this", post.title
730+
assert_equal "changed via assign_attributes", post.body
731+
732+
assert_raises(ActiveRecord::ReadonlyAttributeError) do
733+
post.update(title: "changed via update", body: "changed via update")
734+
end
735+
assert_equal "cannot change this", post.title
736+
assert_equal "changed via assign_attributes", post.body
737+
738+
assert_raises(ActiveRecord::ReadonlyAttributeError) do
739+
post[:title] = "changed via []="
740+
end
741+
post[:body] = "changed via []="
742+
assert_equal "cannot change this", post.title
743+
assert_equal "changed via []=", post.body
744+
745+
post.save!
746+
747+
post = Post.find(post.id)
748+
assert_equal "cannot change this", post.title
749+
assert_equal "changed via []=", post.body
750+
end
751+
752+
def test_readonly_attributes_on_a_new_record
753+
assert_equal Set.new([ "title" ]), ReadonlyTitlePost.readonly_attributes
754+
755+
post = ReadonlyTitlePost.new(title: "can change this until you save", body: "changeable")
756+
assert_equal "can change this until you save", post.title
757+
assert_equal "changeable", post.body
758+
759+
post.title = "changed via assignment"
760+
post.body = "changed via assignment"
761+
assert_equal "changed via assignment", post.title
762+
assert_equal "changed via assignment", post.body
763+
764+
post.write_attribute(:title, "changed via write_attribute")
765+
post.write_attribute(:body, "changed via write_attribute")
766+
assert_equal "changed via write_attribute", post.title
767+
assert_equal "changed via write_attribute", post.body
768+
769+
770+
post.assign_attributes(body: "changed via assign_attributes", title: "changed via assign_attributes")
771+
assert_equal "changed via assign_attributes", post.title
772+
assert_equal "changed via assign_attributes", post.body
773+
774+
post[:title] = "changed via []="
775+
post[:body] = "changed via []="
776+
assert_equal "changed via []=", post.title
777+
assert_equal "changed via []=", post.body
778+
779+
post.save!
780+
781+
post = Post.find(post.id)
782+
assert_equal "changed via []=", post.title
783+
assert_equal "changed via []=", post.body
784+
end
785+
786+
def test_readonly_attributes_when_configured_to_not_raise
787+
assert_equal Set.new([ "title" ]), NonRaisingPost.readonly_attributes
788+
789+
post = NonRaisingPost.create(title: "cannot change this", body: "changeable")
790+
assert_equal "cannot change this", post.title
791+
assert_equal "changeable", post.body
792+
793+
post = Post.find(post.id)
794+
assert_equal "cannot change this", post.title
795+
assert_equal "changeable", post.body
796+
797+
post.title = "changed via assignment"
798+
post.body = "changed via assignment"
799+
post.save!
800+
post.reload
801+
assert_equal "cannot change this", post.title
802+
assert_equal "changed via assignment", post.body
803+
804+
post.write_attribute(:title, "changed via write_attribute")
805+
post.write_attribute(:body, "changed via write_attribute")
806+
post.save!
807+
post.reload
808+
assert_equal "cannot change this", post.title
809+
assert_equal "changed via write_attribute", post.body
810+
811+
post.assign_attributes(body: "changed via assign_attributes", title: "changed via assign_attributes")
812+
post.save!
813+
post.reload
814+
assert_equal "cannot change this", post.title
815+
assert_equal "changed via assign_attributes", post.body
816+
817+
post.update(title: "changed via update", body: "changed via update")
697818
post.reload
698819
assert_equal "cannot change this", post.title
820+
assert_equal "changed via update", post.body
699821

700-
post.update(title: "try to change", body: "changed")
822+
post[:title] = "changed via []="
823+
post[:body] = "changed via []="
824+
post.save!
701825
post.reload
702826
assert_equal "cannot change this", post.title
703-
assert_equal "changed", post.body
827+
assert_equal "changed via []=", post.body
704828
end
705829

706830
def test_unicode_column_name

activerecord/test/cases/helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
# Quote "type" if it's a reserved word for the current connection.
3131
QUOTED_TYPE = ActiveRecord::Base.connection.quote_column_name("type")
3232

33+
ActiveRecord.raise_on_assign_to_attr_readonly = true
34+
3335
def current_adapter?(*types)
3436
types.any? do |type|
3537
ActiveRecord::ConnectionAdapters.const_defined?(type) &&

0 commit comments

Comments
 (0)