Automatic Rails Model Notification Concerns

This ActiveRecord Concern is a module I am particularly proud of. It was developed for an application that had the need for an advanced notification system on a whole slew of database changes. Rather than just wire up a basic notification job to each controller action that triggered each model change, I elected to write a model concern that automatically triggered the notification system on different ActiveRecord changes.

The concerns worked amazingly well, and assisted in not only keeping our controllers very light, but also meant that database changes could not escape notifying users of the change.


# This concerns allows you to directly hookup ActiveRecord model changes
# directly into a system-wide notification system using ActiveSupport
# Callbacks. Jobs can be created to reflect the exact work you want done
# when a specific event occurs in the lifecycle of the model you want
# to be notified on.
#
# ==== Example
#   class MyModel < ApplicationRecord
#     include Notifyable
#
#     notify :on_create, :handler_job
#
module Notifyable
  # Make this module a concerns and include the ActiveSupport callbacks module
  extend ActiveSupport::Concern
  include ActiveSupport::Callbacks

  included do
    # Add a has_many model association for the notifications events.
    has_many :notification_events, as: :item

    # This code opens up the parent class and generates several methods
    # directly into it providing the core foundation of the
    # Notifyable concerns. It declares the available callbacks
    # and runs the associated callback when the custom defined
    # calback is triggered.
    %w(on_initialize on_update on_save on_create on_commit on_destroy on_find).each {|name|
      module_eval <<-RUBY, __FILE__, __LINE__ + 1
        define_callbacks :#{name}
        #{name.gsub('on', 'after')} :notify_#{name}
        
        def notify_#{name.to_s}
          run_callbacks :#{name} do
            @invocation = :#{name}
          end
        end
      RUBY
    }

    # Opens up the calling class so methods can be redefined on
    # the current object. We need to add the +notify+ method so
    # that we can define what callbacks should be watched to
    # trigger notifications.
    #--
    # FIXME: Class variables for handlers are bugged if
    # FIXME: different models use different handlers. I
    # FIXME: would love to refactor this so that you can 
    # FIXME: provide a &block instead of just a handler
    # FIXME: name/symbol.
    #++
    # Callbacks are always appended *after* the source event
    # declared; so that +:on_save+ will actually declare itself
    # as a +:after_save+ on the parent ActiveRecord class.
    class << self
      def notify(name, *handlers)
        @@handlers ||= HashWithIndifferentAccess.new
        @@handlers.store(name, handlers)
        set_callback(name, :after, :handler_callback)
      end
    end

  end


  protected

  # Execute the callback for each handler invocation. It
  # is expected that there will be a corresponding ActiveJob
  # to handle the notification within a +Notification+
  # namespace with the same name as the invocation class,
  # followed by the handler name.
  #
  # Example:
  #   +Notification::MyClassHandlerJob+
  def handler_callback
    @@handlers[@invocation].each do |handler|
      eval "Notification::#{self.class.name}#{handler.to_s.classify}.perform_later(self, '#{@invocation}')"
    end
  end

end

As directed in the comments of the concerns, the only thing needed to make this work is a method call in your model telling the module when the handler should be notified of the change, and what trigger should it attach. For example, lets assume you have a TodoItem model:

class TodoItem < ApplicationRecord
# Include the Notifyable concerns
include Notifyable

# Instruct Notifyable on which callback and handler should be used.
notify :on_create, :handler_job
end

Lastly, as you can see from the concerns, you now need to create an ActiveJob class called: Notification::TodoItemHandlerJob which will be enqueued whenever a ‘TodoItem’ database record is created. This job can do whatever you need to do in order to notify the relevant stakeholders of a new TodoItem record.

Whats more, this will be done asynchronously to the main thread of your application which should make your application more performant.

There are a few improvements I would eventually like to make to this:

  1. I’d like to package it as a gem and monkey patch it to the abstract ApplicationRecord class so that the concerns is automatically included on all models and that the include statement is not required.
  2. I’d like to be able to pass a &block to #notify instead of the handler job symbol because then you could eliminate that disgusting eval in the protected #handler_callback
  3. There is a bug in this concerns regarding the class variables the concerns. Because the class variables are stored at the class level, and not the ‘consuming’ class using the concerns, the handlers were being overwritten between models (which means that each model most use the same handler symbol). Fortunately, in the project that uses this, the conformity of all the notification handler jobs using the suffix ‘HandlerJob’ was deemed preferred anyway so it was not seen as a big problem. Alas, I’d very much like to fix it one day.

Skipping an ActiveRecord Callback Programatically

I am a massive fan of ActiveSupport callbacks and use them frequently. This allows me to chain behaviours together; essentially using data storage as an event based system to enforce business logic. An example of this, is to use after_create_commit callbacks to automatically trigger an email that needs to be sent; such as welcome user email, to automatically generate some accounting record, or an admin email notification.

This does have some drawbacks however. It means that you really need to have a good grasp of the domain logic; and means that it becomes critically important to choose the correct way to update the record (update_column vs update_attribute) lest you fail to trigger important business logic, or trigger them when you shouldn’t. But generally, when used appropriately I find them invaluable. But sometimes you might find yourself in a situation where you need to run some code (such as in a rake task) where you cannot influence the method used to update the database, but the callbacks must not be executed.

As I said, if you have direct control over the ActiveRecord relation, then its easy:

@object.update_column(:the_attribute, 'value')
@object.update_columns(attributes)

These will update the database, but skip validations and callbacks.

But if the updates are being triggered by another class/code outside of the scope or control of where you are, this wont work. Perhaps you care calling a method of a related class, and that method specifies .update_attribute and you cannot change it. What then?

Fortunately, there is a solution.

Lets say you have a class definition:

class User < ActiveRecord::Base
after_save :my_method
end

There are 2 ways you can save the object without triggering the callback.

Method #1:

User.send(:create_without_callbacks)
User.send(:update_without_callbacks)

But thats super gross! Using .send is a code smell if ever there was one.

Method #2:

User.skip_callback(:save, :after, :my_method)
User.create!

This is much more civilised. What’s more, after you’ve run your rspec, mock, rake task whatever: you can reset the callback with: User.set_callback(:save, :after, :my_method).

Changes to ActiveRecord::Dirty in Rails 5.2

A while ago I wrote a small post on how to manually mark an ActiveRecord model attribute as dirty. Since Rails 5.2 has some fundamental changes to ActiveRecord::Dirty.

Specifically, three methods have been deprecated and changed in 5.2. These are:

  • attribute_changed?
  • changes, and
  • previous_changes

Using these methods in a Rails 5.2 application will now result in the following message (or a variation of it) being outputted in your logs:

DEPRECATION WARNING: The behavior of `attribute_changed?` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_change_to_attribute?` instead.

attribute_changed? and changed? remain the same when called within a before_save callback. But when used in an after callback there are new methods you should use instead, which the Rails Core Team believe should reduce code ambiguity of changed? before saving to the database: will_save_change_to_attribute? and has_changes_to_save?


In summary, if upgrading to Rails 5.2 and after modifying an object and after saving to the database, or within after_save:

  • attribute_changed? should now be saved_change_to_attribute?
  • changed? should now be saved_changes?
  • changes should now be saved_changes
  • previous_changes has no replacement, since the behavior for it changes.

And (optional; less ambiguity, more readable, but longer) after modifying an object and before saving to the database, or within before_save:

  • attribute_changed? should now be will_save_change_to_attribute?
  • changed? should now be has_changes_to_save?
  • changes should now be changes_to_save

Adding Wiki Style Functionality to Your Rails Site Using 'acts_as_versioned'

If you need to add basic wiki style functionality to your Ruby on Rails models, there is a really easy way to get similar model versioning without having to resort to cutting the code yourself.

The acts_as_versioned ‘plugin’ has been available for quite some time, but its been made far better by it now becoming a gem instead of an old-school plugin. The authors have gone to considerable effort to make it as painless as possible to use.

This post, is designed to give you a brief over-view into how to get up and running with with models which ‘acts_as_versioned’.  Because its the current version (at time of posting) and because its awesome, this walk-though assumes that you are using Rails 3, not 2.  The instructions for Rails 2 sites are similar, but you’ll need to tweak this for it to work.

First, you need to grab the gem:

sudo gem install acts_as_versioned

Next, add the dependency to the ‘Gemfile’, it doesn’t matter too much where it goes, I stuck it somewhere in the middle:

gem 'acts_as_versioned', '0.6.0'

Next, just under the ‘ActiveRecord::Base’ line in the model’s class file, instruct the class that its to act as a versioned model.

class Article < ActiveRecord::Base
  acts_as_versioned
end

Then, in the migration file you need to execute the model's method to create the version table.  This is key because the acts_as_versioned gem actually creates an additional database table to house all the previous versions of a given record.  Obviously, you need to delete the table is the schema is taken down.  My migration now looks like:

class CreateArticles < ActiveRecord::Migration
  def self.up
    create_table :article do |t|
      t.string :title
      t.string :body
      t.integer :user_id

      t.timestamps
    end
    Article.create_versioned_table
  end

  def self.down
    drop_table :articles
    Article.drop_versioned_table
  end
end

The key method is the

Article.create_versioned_table

which creates the version table of the model. Now, get rake to create the database:

 rake db:migrate

Thats it!  Its done.  Using acts_as_versioned is simple. I'll provide some examples, where '@article' represents an instance of a model setup 'acts_as_versioned'. To find the current version of an article you can use the version property:

@article.version 

But just performing a normal ActiveRecord lookup returns the most current version anyway, so to revert to a previous version use the revert_to method on an article instance:

@article.revert_to(version_number)

You can save (just like you've done a hundred times before) a previous version as the current on by using the save method. The save on a reverted articles will just create a new version.

To get the number of versions:

@article.versions.size

Since '@article.versions' returns an array of versions, you can do neat things like this:

History

<% for version in @article.versions.reverse %> Version <%= version.version %> <%= link_to '(revert to this version)', :action => 'revert_to_version', :version => version.id, :id => @article %>
<% end %>

Obviously for this to work, you'd need to create a 'revert_to_version' action in the appropriate controller, but you get the idea.

acts_as_versioned is an amazing piece of work, and aside from the wiki-like functionality it gives you for very little effort, I can imagine scenarios such as audit and trace logs and "undo" features which could really benefit from this little gem.