Validates immutable: true

I am moving my eng team over to using Rails heavily right now, and I have found that our app needs a lot of fields in some models that can’t be change after initial create. I realized this is pretty common. I can start putting in an “immutable” method DSL, but this actually feels like a very natural validation.

If I work on this as a patch into ActiveModel Validation, would core folks be open to that as a new validation?

That’s an interesting idea. One thing that Rails offers which may help you out with this feature right now is the idea of validation context. You want to be able to create a value, but never change it thereafter. You can get this by scoping your validation like this:

validates :foo, presence: true, on: :create
validate :foo_cannot_change, on: :update

...

def foo_cannot_change
  errors.add :foo, 'cannot be changed after creation' if will_save_change_to_foo?
end

If you subclass the EachValidator, as here: Active Record Validations — Ruby on Rails Guides you might be able to build something general purpose that can be invoked on any attribute you choose.

I would see what you can build with these tools first, and then try to generalize. You may find that it’s easy enough to add, but you may also discover some edge cases that require additional thought.

Walter

1 Like

I like that as an elegant workaround.

Also keep in mind there is the attr_readonly accessor that already exists. Note, that it does not raise a validation error but simply ignores any further update.

You could build on that with something like this to trigger a validation error:

validates_each *readonly_attributes, on: :update do |record, attr|
  record.errors.add attr, "#{attr} is read only" if :"will_save_change_to_#{attr}?"
end

I haven’t tested this but seems like it should work. Since it’s completely generic of your model you could even abstract this into your ApplicationRecord with something like this:

class ApplicationRecord < ActiveRecord::Base
  def self.validate_readonly!
    validates_each *readonly_attributes, on: :update do |record, attr|
      record.errors.add attr, "#{attr} is read only" if :"will_save_change_to_#{attr}?"
    end
  end
end

Now your subclass just has:

class Widget < ApplicationRecord
  attr_readonly :foo
  validate_readonly!
end

This setup means you need to mark read only attributes first then setup your validation. You could work around that by reading the list of attributes within the validator:

validate on: :update do |record|
  record.class.readonly_attributes.each do |attr|
    record.errors.add attr, "#{attr} is read only" if :"will_save_change_to_#{attr}?"
  end
end

Now you could just put that in your ApplicationRecord directly and all models would get the validator. Models not using attr_readonly it would be a no-op but any use it would trigger the validation error.