Implementing read-only models with ActiveRecord


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.