I’m wrapping up an app with some ActiveRecord models that are based on fixed data (i.e. data tables that are not created or managed by the Rails app.) This is actually a pretty useful way to access some types of external data in Rails, but problems cascade if/when you accidentally write to the supposedly r/o data.
A read-only model
The obvious way to do this is at the Model level. After running across this post from “Zobies blog” (which tipped me off that overriding the readonly? method covered a lot of the functionality) and struggling a bit with testing (how do you create an instance for testing when the model definition prevents it?) I came up with this really simple module.
module ReadOnlyModel # Forces model to be read-only by raising errors on write operations. extend ActiveSupport::Concern included do attr_readonly(*column_names) # Required to block update_attribute and update_column end def readonly? true # Does not block destroy or delete end def destroy raise ActiveRecord::ReadOnlyRecord end def delete raise ActiveRecord::ReadOnlyRecord end end
The trick to easy testing was to use the ActsAsFu gem to create a test class, and make it ReadOnly after creating test instances:
require 'spec_helper' describe 'ReadOnlyModel' do before(:all) do build_model :read_only_fus do string :field1 end # To create an instance for testing, you must create it first... @instance = ReadOnlyFu.create(field1: 'some text') # ... before applying the readonly module! class ReadOnlyFu include ReadOnlyModel end end it 'raises error on create' do expect{ReadOnlyFu.create}.to raise_error(ActiveRecord::ReadOnlyRecord) end it 'raises error on save' do expect{@instance.save}.to raise_error(ActiveRecord::ReadOnlyRecord) end it 'raises error on update_attributes' do expect{@instance.update_attributes(field1: 'other text')}.to raise_error(ActiveRecord::ReadOnlyRecord) end it 'raises error on update_attribute' do # Raises ActiveRecordError, not ReadOnlyRecord. expect{@instance.update_attribute(:field1, 'other text')}.to raise_error(ActiveRecord::ActiveRecordError) end it 'raises error on update_column' do # Raises ActiveRecordError, not ReadOnlyRecord. expect{@instance.update_column(:field1, 'other text')}.to raise_error(ActiveRecord::ActiveRecordError) end it 'raises error on delete' do expect{@instance.delete}.to raise_error(ActiveRecord::ReadOnlyRecord) end it 'raises error on destroy' do expect{@instance.destroy}.to raise_error(ActiveRecord::ReadOnlyRecord) end end
Belt and suspenders
I’ve tested this and don’t see any unfortunate side-effects, and I’ve got no reason to think that a model including this module could ever write to the underlying database table. But sometimes it pays to play it safe. Who knows what evil lurks in the heart of ActiveRecord (or will change in a future version.)
For added protection, you can just create a database user with limited rights that allow reading but not writing to the affected tables, and let your model access the data using the limited-access user. Typically a table like this will reside in a different database, so it’s really not any extra configuration. Rails aficionados may poo-poo this as extra work (like setting up referential integrity in database relationships) but this ex-IT manager thinks it’s a good idea.