Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Bypass readonly? when saving ActiveRecord

I use the readonly? function to mark my Invoice as immutable after they've been sent; for by InvoiceLines, I simply proxy the readonly? function to the Invoice.

A simplified example:

class Invoice < ActiveRecord::Base
  has_many :invoice_lines
  def readonly?; self.invoice_sent?  end
end

def InvoiceLine < ActiveRecord::Base
  def readonly?; self.invoice.readonly?  end
end

This works great, except that in one specific scenario I want to update an InvoiceLine regardless of the readonly? attribute.

Is there are way to do this?

I tried using save(validate: false), but this has no effect. I looked at persistence.rb in the AR source, and that seems to just do:

def create_or_update
  raise ReadOnlyRecord if readonly?
  ...
end

Is there an obvious way to avoid this?

A (somewhat dirty) workaround that I might do in Python:

original = line.readonly?
line.readonly? = lambda: false
line.save()
line.readonly? = original

But this doesn't work in Ruby, since functions aren't first-class objects ...

like image 866
Martin Tournoij Avatar asked Nov 29 '22 10:11

Martin Tournoij


2 Answers

You can very easily redefine a method in an instantiated object, but the syntax is definition rather than assignment. E.g. when making changes to a schema that required a tweak to an otherwise read-only object, I have been known to use this form:

line = InvoiceLine.last
def line.readonly?; false; end

Et voila, status overridden! What's actually happening is a definition of the readonly? method in the object's eigenclass, not its class. This is really grubbing around inside the guts of the object, though; outside of a schema change it's a serious code smell.

One crude alternative is forcing Rails to write an updated column directly to the database:

line.update_columns(description: "Compliments cost nothing", amount: 0)

and it's mass-destruction equivalent:

InvoiceLine.where(description: "Free Stuff Tuesday").update_all(amount: 0)

but again, neither should appear in production code outside of migrations and, very occasionally, some carefully written framework code. These two bypass all validation and other logic and risk leaving objects in inconsistent/invalid states. It's better to convey the need and behaviour explicitly in your model code & interactions somehow. You could write this:

class InvoiceLine < ActiveRecord::Base
  attr_accessor :force_writeable

  def readonly?
    invoice.readonly? unless force_writeable
  end
end

because then client code can say

line.force_writable = true
line.update(description: "new narrative line")

I still don't really like it because it still allows external code to dictate an internal behaviour, and it leaves the object with a state change that other code might trip over. Here's a slightly safer and more rubyish variant:

class InvoiceLine < ActiveRecord::Base
  def force_update(&block)
    saved_force_update = @_force_update
    @_force_update = true
    result = yield
    @_force_update = saved_force_update
    result
  end

  def readonly?
    invoice.readonly? unless @_force_update
  end
end

Client code can then write:

line.force_update do
  line.update(description: "new description")
end

Finally, and this is probably the most precision mechanism, you can allow just certain attributes to change. You could do that in a before_save callback and throw an exception, but I quite like using this validation that relies on the ActiveRecord dirty attributes module:

class InvoiceLine < ActiveRecord::Base
  validate :readonly_policy

  def readonly_policy
    if invoice.readonly?
      (changed - ["description", "amount"]).each do |attr|
        errors.add(attr, "is a read-only attribute")
      end
    end
  end
end

I like this a lot; it puts all the domain knowledge in the model, it uses supported and built-in mechanisms, doesn't require any monkey-patching or metaprogramming, doesn't avoid other validations, and gives you nice error messages that can propagate all the way back to the view.

like image 97
inopinatus Avatar answered Dec 06 '22 18:12

inopinatus


I ran into a similar problem with a single readonly field and worked around it using update_all.

It needs to be an ActiveRecord::Relation so it would be something like this...

Invoice.where(id: id).update_all("field1 = 'value1', field2 = 'value2'")

like image 33
Derek Avatar answered Dec 06 '22 18:12

Derek