ActiveState
A tiny gem for easily using the state design pattern with ActiveRecord models. State objects will be automatically created when the model is instantiated. States can use the ActiveModel::Validations
DSL for creating validations, which will be run when the model is validated. Scopes for individual states can easily be created on the model.
Installation
Add this line to your application's Gemfile:
gem 'active_state'
And then execute:
$ bundle
Or install it yourself as:
$ gem install active_state
Usage
Let's assume that we have a model called Report
. Every Report
has an author - a User
. Reports also have a url leading to an attached file (this example simply uses a string).
Reports are approved or rejected by a User
. If a Report
is rejected, the reviewer has to provide a reason for rejecting it. Reports cannot be reviewed by the User
who created them.
A Report
can therefore be in one of three states - Pending
, Approved
or Rejected
. In every state, different validations have to be run and methods will behave differently. Instead of polluting the model with many conditionals, the state pattern can be used to split the code into multiple short and readable classes.
The migration
class CreateReports < ActiveRecord::Migration[6.0]
def change
create_table :reports do |t|
t.references :author, foreign_key: { to_table: :users }
t.references :reviewer, foreign_key: { to_table: :users }
t.string :file_url
t.datetime :reviewed_at
t.text :reason_for_rejection
t.string :state_name # This is for ActiveState
t.
end
end
end
The model
class Report < ApplicationRecord
include ActiveState::Model
self.initial_state = Pending
belongs_to :author, class_name: 'User'
belongs_to :reviewer, class_name: 'User', optional: true
# a url has to be present in all states
validates_presence_of :file_url
# create scopes for different states
# you can now call Report.pending and Report.approved
# custom names for scopes are also possible
# Report.thrown_out_the_window will return reports in the Rejected state
scope_for_state Pending, Approved, thrown_out_the_window: Rejected
# delegate methods that will be implemented in the state objects
delegate :approve_by, :reject_by, to: :state
end
The states
The states should probably be put inside a namespace to avoid naming collisions. In this case, they are put inside the Report class, but that is not required for ActiveState to work. To satisfy the Rails autoloading mechanism, these files need to be put in app/*/reports
. It is up to you if you put the reports
directory into app/models
or into a new directory in app
(possibly something like app/states
).
Pending:
class Report
class Pending < ActiveState::Base
def approve_by(user)
review_by user
context.state = Approved.new context
end
def reject_by(user, reason:)
review_by user
context.reason_for_rejection = reason
context.state = Rejected.new context
end
private
def review_by(user)
context.reviewer = user
context.reviewed_at = Time.now
end
end
end
Because some validations and implementations of methods will be the same in both Approved
and Rejected
states, we can make a common superclass for both. This is done simply to show that such a thing is possible, but it is up to us to make sure that the Reviewed
state will never actually be assigned to a model. Treat it like an abstract class.
class Report
class Reviewed < ActiveState::Base
# For validating attributes of the model, we need to get access to them
delegate :reviewer, :reviewed_at, to: :context
validates_presence_of :reviewer, :reviewed_at
def approve_by(_user)
raise StandardError, 'Report already reviewed'
end
def approve_by(_user, _reason)
raise StandardError, 'Report already reviewed'
end
end
end
The Approved
state does not need to do anything more than the Reviewed
state. However, it would not make sense to have the state representing rejection subclass a state representing approval. This is why creating an empty subclass might be justifiable in this situation.
class Report
class Approved < Reviewed
end
end
Rejected:
class Report
class Rejected < Reviewed
delegate :reason_for_rejection, to: :context
validates_presence_of :reason_for_rejection
end
end
License
The gem is available as open source under the terms of the MIT License.