28

I have an object that inherits from ActiveRecord, yet it has an attribute that is not persisted in the DB, like:

 class Foo < ActiveRecord::Base
   attr_accessor :bar
 end

I would like to be able to track changes to 'bar', with methods like 'bar_changed?', as provided by ActiveModel Dirty. The problem is that when I try to implement Dirty on this object, as described in the docs, I'm getting an error as both ActiveRecord and ActiveModel have defined define_attribute_methods, but with different number of parameters, so I'm getting an error when trying to invoke define_attribute_methods [:bar].

I have tried aliasing define_attribute_methods before including ActiveModel::Dirty, but with no luck: I get a not defined method error.

Any ideas on how to deal with this? Of course I could write the required methods manually, but I was wondering if it was possible to do using Rails modules, by extending ActiveModel functionality to attributes not handled by ActiveRecord.

Santiago Palladino
  • 3,522
  • 2
  • 26
  • 36

4 Answers4

44

I'm using the attribute_will_change! method and things seem to be working fine.

It's a private method defined in active_model/dirty.rb, but ActiveRecord mixes it in all models.

This is what I ended up implementing in my model class:

def bar
  @bar ||= init_bar
end
def bar=(value)
  attribute_will_change!('bar') if bar != value
  @bar = value
end
def bar_changed?
  changed.include?('bar')
end

The init_bar method is just used to initialise the attribute. You may or may not need it.

I didn't need to specify any other method (such as define_attribute_methods) or include any modules. You do have to reimplement some of the methods yourself, but at least the behaviour will be mostly consistent with ActiveModel.

I admit I haven't tested it thoroughly yet, but so far I've encountered no issues.

Alessandro
  • 788
  • 10
  • 13
  • As I mentioned in the answer, the `init_bar` method is just used to initialise the attribute. If you need some initialisation logic you'll have to define it, otherwise you can just ignore it. – Alessandro Apr 24 '15 at 09:30
  • Oh it's a method that I have to define... it's not a built in method right? – Jwan622 Apr 24 '15 at 14:15
  • Correct. You have to define it if you need an initialiser. – Alessandro Apr 28 '15 at 09:09
3

ActiveRecord has the #attribute method (source) which once invoked from your class will let ActiveModel::Dirty to create methods such as bar_was, bar_changed?, and many others.

Thus you would have to call attribute :bar within any class that extends from ActiveRecord (or ApplicationRecord for most recent versions of Rails) in order to create those helper methods upon bar.

Edit: Note that this approach should not be mixed with attr_accessor :bar

Edit 2: Another note is that unpersisted attributes defined with attribute (eg attribute :bar, :string) will be blown away on save. If you need attrs to hang around after save (as I did), you actually can (carefully) mix with attr_reader, like so:

attr_reader :bar
attribute :bar, :string

def bar=(val)
  super
  @bar = val
end
steve
  • 3,276
  • 27
  • 25
Cristiano Mendonça
  • 1,220
  • 1
  • 10
  • 21
  • This seemed promising however it did not work for me. I do not believe the `attribute` method defines modifier methods which implement the `ActiveModel::Dirty` interface – steve Oct 07 '19 at 19:16
  • I take it all back. As long as you _only_ add `attribute :bar, :string` (do not mix with `attr_accessor :bar` or defining custom setter), this works great and would be my preferred approach... when SO unlocks my vote I will upvote – steve Oct 07 '19 at 22:39
  • actually I realized it _is_ possible (and for me was necessary) to mix with `attr_accessor`, because unpersisted `attribute :bar` gets blown away on save, and I needed it in a callback.... I guess I'll edit again – steve Oct 08 '19 at 20:29
  • Or I'm not sure -- does it merit a followup answer? – steve Oct 08 '19 at 20:32
  • 1
    I think the path you took (i.e., editing the answer by adding additional info) was the right one. Thanks once again @steve – Cristiano Mendonça Oct 11 '19 at 10:56
2

I figured out a solution that worked for me...

Save this file as lib/active_record/nonpersisted_attribute_methods.rb: https://gist.github.com/4600209

Then you can do something like this:

require 'active_record/nonpersisted_attribute_methods'
class Foo < ActiveRecord::Base
  include ActiveRecord::NonPersistedAttributeMethods
  define_nonpersisted_attribute_methods [:bar]
end

foo = Foo.new
foo.bar = 3
foo.bar_changed? # => true
foo.bar_was # => nil
foo.bar_change # => [nil, 3]
foo.changes[:bar] # => [nil, 3]

However, it looks like we get a warning when we do it this way:

DEPRECATION WARNING: You're trying to create an attribute `bar'. Writing arbitrary attributes on a model is deprecated. Please just use `attr_writer` etc.

So I don't know if this approach will break or be harder in Rails 4...

Tyler Rick
  • 9,191
  • 6
  • 60
  • 60
  • This is the only answer the really addresses the question, but there has to be a way to do this without deprecation warnings. Anyone know what that is? – Gerry Gleason Nov 06 '14 at 15:46
0

Write the bar= method yourself and use an instance variable to track changes.

def bar=(value)
  @bar_changed = true
  @bar = value
end

def bar_changed?
  if @bar_changed
    @bar_changed = false
    return true
  else
    return false
  end
end
jvatic
  • 3,485
  • 2
  • 20
  • 27
  • Yeah, something like that is what I ended up doing, but I was wondering if there was a way to do it using ActiveModel methods. – Santiago Palladino Apr 22 '11 at 00:04
  • its simple enough to do with basic ruby, i think using ActiveModel for this is extreme overkill. If this is part of a bigger picture relavent to ActiveModel, you can make table-less models (see http://railscasts.com/episodes/219-active-model) and could use similar technique for table-less attributes, but again would be overkill for such a simple task... keeping it simple is best – jvatic Apr 22 '11 at 02:43
  • Reusing ActiveModel::Dirty and ActiveModel::AttributeMethods (which are *already* included into ActiveRecord::Base) *WOULD* keep it simple, if only that were possible. I don't think that reusing methods (from a class's ancestors) is overkill at all. And I blame ActiveRecord for overriding define_attribute_methods such that you can't reuse the define_attribute_methods method from ActiveModel::AttributeMethods. Why does it have to be so difficult to have a mix of both persisted and non-persisted attributes in a model? – Tyler Rick Jan 22 '13 at 22:43
  • This approach might work in some cases, but one thing it *doesn't* do that ActiveModel::Dirty would do for you is include this change in the hash returned by the `changes` method. – Tyler Rick Jan 23 '13 at 00:01