Readonly Attributes in Rails
In this article, we'll learn how to mark certain attributes as readonly on your active record models, to prevent them from any future updates after the record is created and saved to the database. We'll also learn how Rails implements this feature behind the scenes.
Sometimes, you want to prevent changes to certain attributes on your Active Record models after they are initialized and saved in the database. For example, you'd like to set the users' account number upon registration and want to ensure that it's never updated in the future.
Now you could override the write_attribute method or insert a before_save / before_update callback to listen for the updates to these attributes, but it’d be nice if there was a Rails-style declarative solution to handle this.
Turns out, there is!
Rails provides the attr_readonly method to mark some attributes on your Active Record models as readonly, to prevent them from further modification. For example, the following code snippet marks the account_id property as readonly on a User model.
class User < ApplicationRecord
attr_readonly :account_id
end
You can create a new User record with the initial value for account_id, but Rails won’t let you update its value once it’s saved in the database. If you try to change it, the value will only change in memory, but won’t be persisted in the database on calling save. You can only set the value of a readonly attribute when the object isn't saved in the database.
Example Usage
This example, along with the tests that follow, shows how to use readonly attributes.
Note: Starting version 7.1 and above, Rails will raise an error if you try to modify a readonly attribute (covered in the next section).
class Book < ApplicationRecord
attr_readonly :title
end
class BookTest < ActiveSupport::TestCase
test "title is readonly" do
book = Book.create(title: 'Harry Potter')
assert_equal 'Harry Potter', book.title
# Try changing the title
book.update(title: "Chamber of Secrets")
# Changed in memory
assert_equal "Chamber of Secrets", book.title
# Reload the book from database
book.reload
# Change was not persisted in database
assert_equal "Harry Potter", book.title
## Convenience methods
# Get a set of all readonly attributes on the model
assert_equal ["title"].to_set, Post.readonly_attributes
# Check if an attribute is readonly
assert Post.readonly_attribute? "title"
end
end
Latest Rails will Raise an Error!
Until very recently, the updates were getting ignored silently. That meant assignment would succeed but silently not write to the database. However, this behavior was changed a few months ago in this pull request: Raise on assignment to readonly attributes
If you try to update a readonly attribute on a model that’s already saved in the database, Rails will raise ActiveRecord::ReadonlyAttributeError. Set the config.active_record.raise_on_assign_to_attr_readonly setting to false to prevent Rails from raising this error.
How Rails Implements Readonly Attributes
This functionality is defined in the ActiveRecord::ReadonlyAttributes module, which is a concern. The ActiveRecord::Base class includes it and hence all your Active Record models get this behavior out of the box.
# activerecord/lib/active_record/readonly_attributes.rb
module ActiveRecord
module ReadonlyAttributes
extend ActiveSupport::Concern
end
end
To learn more about how concerns work, check out this article: Concerns in Rails: Everything You Need to Know
This concern defines a class_attribute named _attr_readonly on your Rails model. By default, it’s set to an empty array.
class_attribute :_attr_readonly, instance_accessor: false, default: []
The attr_readonly method is added as a class method to the Active Record model, such as User. That’s why you can call it directly inside the class.
Here’s a simplified implementation of this method.
module ClassMethods
def attr_readonly(*attributes)
self._attr_readonly |= attributes.map(&:to_s)
end
def readonly_attributes
_attr_readonly
end
def readonly_attribute?(name) # :nodoc:
_attr_readonly.include?(name)
end
end
As you can see, when we mark an attribute as readonly using the attr_readonly method, Rails will simply store it in the _attr_readonly array, which we saw in the previous snippet.
Later, when the record is saved, Rails will exclude all the attributes in this array from the list of attributes to be saved in the database. See the ActiveRecord::Associations::CollectionAssociation#merge_target_lists method to learn more.
💡 Your challenge for the day: Open the latest Rails source and go to thereadonly_attributes.rbfile. Try to understand theHasReadonlyAttributesmodule and how it works.
Additional Resources
Sign up for my newsletter
Let's learn to become better developers.
Comments (4)
Thanks for deep understanding. 🙂 Regarding what you asked for `HasReadonlyAttributes` module, As you said in your blog regarding config `config.active_record.raise_on_assign_to_attr_readonly`, if you set this config to `true` then this module is included dynamically and based on read only attributes rules violation it will raise an error instead of ignored if silently. Is this understanding OK ? 🤔
Yes, that's exactly correct, Good work!
Great content
Thanks, Marcelo!