There’s an important probability that your Rails app incorporates one of many gems offering so known as state machine implementation. There’s occasion a higher probability that it will likely be aasm previously generally known as acts_as_state_machine
. Btw. Who remembers acts_as_hasselhoff? — okay, boomer. The aasm does quite a bit when included into your ActiveRecord
mannequin — the query is do you actually need all these issues?
My drawback with aasm
I used to be struck by reckless use of this gem so many instances that very first thing I do after becoming a member of a brand new challenge is working cat Gemfile | grep aasm
and right here comes the meme which I made ~1.5 years in the past:
My foremost concern with use of this gem is that you just in all probability don’t want all of the options it provides. Extra options means extra temptation to make use of them. Higher use throughout the codebase means extra coupling to exterior library which may be incompatible with upcoming Rails variations, blocking you from improve or making it fairly pricey. It’s a must to learn one more changelog to verify if there aren’t any breaking modifications or some refined conduct change working your critical enterprise software into issues.
Subsequent factor is that it promotes patterns like callbacks which I personally see as an enormous drawback inside complicated rails functions. I desire specific code, callbacks aren’t such. Ackchyually callbacks are part of the framework, I do know, however let’s depart this dialogue for an additional place in time.
It’s additionally wrestle for all of the IDEs, Language Server Suppliers to seek out definitions of strategies outlined by aasm’s DSL and you might be compelled to make use of grep
, learn the code rigorously and resolve whether or not these energetic!
technique comes from or the place my pending
scope is outlined.
I’ve lately realized that it additionally autogenerates constants for every state so I don’t should. I’ve lately noticed Transaction::STATUS_FAILED
someplace, it took fairly a while to determine the origin of this fixed which wasn’t explicitly outlined throughout the Transaction
class.
These free lunches may be tasty, however ultimately you can be compelled to pay for them.
Final, however not least, having attribute like state or standing usually recommend poor design in your codebase, however that’s a very completely different story.
Place to begin
9 years previous legacy Rails app backing very profitable enterprise. Let’s take a look at a number of the particulars:
-> ruby -v
ruby 3.3.0 (2023-12-25 revision 5124f9ac75)
-> bin/rails r "p Rails.model"
"7.1.3.2"
-> bundle record | wc -l
319
-> rg embrace AASM -l | wc -l
33
Few makes use of of mannequin.aasm.states.map(&:identify)
Two makes use of of SwitchRequest.aasm.states_for_select
to offer choices for some <choose>
tags.
Rails, the white knight
As a Rails developer, you’re in all probability aware of enum which was launched in Rails 6.0 and allowed to declare an attribute the place the values map to integers within the database. It advanced a bit with subsequent framework variations, however Rails 7.1 lastly introduced all of the options required to switch all the aasm makes use of within the codebase I’m presently engaged on.
Let’s make use of this straightforward class for example for our additional work:
class Transaction < ApplicationRecord
embrace AASM
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :profitable
CANCELED = :canceled
aasm column: :standing do
state PROCESSING, preliminary: true
state FAILED
state SUCCESSFUL
state CANCELED
finish
finish
What’s required to get actual the identical conduct
- Scopes like
Transaction.profitable
to question all of the transactions having standingprofitable
— we will have these at no cost fromActiveRecord::Enum
. - Occasion strategies to:
- verify whether or not our object’s standing is
profitable?
— enum acquired you lined - change the standing to
profitable
and runsave!
as we acquired used to it — identical right here, enum will do it’s job right here in the event you nametransaction.profitable!
- verify whether or not our object’s standing is
- Set desired preliminary state for brand spanking new objects — this may be executed by offering
default
key phrase argument toenum
technique, likedefault: PROCESSING
. - Values have to be saved as strings, not as integers within the db — it’s potential since Rails 7.0
- Hopefully there have been no transitions or guards in state machines in our software — but another excuse to not make use of aasm — however I can picture implementing it with
ActiveRecord::Soiled
fairly straightforward. And even higher, implement this conduct as a better stage abstracion and preserve you mannequin dummy on this matter. - No callbacks both, yay!
- Constants containing all of the potential states had been already in place, so these outlined by aasm, like
STATUS_PROCESSING, STATUS_SUCCESSFUL
and so forth had been one thing no one requested for.
Validation of the offered worth
There’s slight distinction in default enum conduct. If the offered a worth doesn’t match specified values, you can be struck with ArgumentError
when making an attempt to assign one.
As talked about earlier, in Rails 7.1 there’s a chance to offer validate: true
key phrase argument. Enum will behave precisely the identical as aasm on this method which checks the validity of offered worth earlier than save leading to ActiveRecord::RecordInvalid
as a substitute.
Present me the code
class Transaction < ApplicationRecord
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :profitable
CANCELED = :canceled
enum :standing,
{ processing: PROCESSING,
failed: FAILED,
profitable: SUCCESSFUL,
canceled: CANCELED
}.transform_values(&:to_s),
default: PROCESSING.to_s,
validate: true
finish
That’s it. Give up clear and comprehensible, isn’t it?
The values quirk
There’s one quirk, you’ve in all probability already observed: transform_values(&:to_s)
. I used to be fairly confused what’s occurring once I’ve offered symbols as values initially. Let’s see how the code regarded like earlier than:
class Transaction < ApplicationRecord
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :profitable
CANCELED = :canceled
enum :standing,
{ processing: PROCESSING,
failed: FAILED,
profitable: SUCCESSFUL,
canceled: CANCELED
},
default: PROCESSING,
validate: true
finish
The documentation hasn’t clearly acknowledged that values ought to be strings. The instance used String
values, however there was no clear expectation about that. Nevertheless, when saving the thing, standing
grew to become nil
in some unspecified time in the future, regardless of assigning ”profitable”
worth.
I wrote a dummy app and take a look at for this particular case apart from the primary software I used to be engaged on. To be 100% certain that there’s no different issue influences this conduct:
require 'bundler/inline'
gemfile do
supply 'https://rubygems.org'
gem 'activerecord'
gem 'sqlite3'
gem 'minitest'
finish
require 'active_record'
require 'sqlite3'
require 'minitest/autorun'
start
db_name = 'enum_test.sqlite3'.freeze
SQLite3::Database.new(db_name)
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: db_name)
ActiveRecord::Schema.outline do
create_table :transactions, drive: true do |t|
t.string :standing
finish
finish
class Transaction < ActiveRecord::Base
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :profitable
CANCELED = :canceled
enum :standing,
{ processing: PROCESSING,
failed: FAILED,
profitable: SUCCESSFUL,
canceled: CANCELED
},
default: PROCESSING,
validate: true
finish
class TestTransaction < Minitest::Check
def test_enum_behavior
transaction = Transaction.new(standing: :profitable)
assert_equal 'profitable', transaction.standing
transaction.save!
assert_equal 'profitable', transaction.reload.standing
finish
finish
guarantee
Dir.glob(db_name + '*').every File.delete(file)
finish
Consequence:
ruby enum.rb
-- create_table(:transactions, {:drive=>true})
-> 0.0076s
Run choices: --seed 3038
# Working:
F
Completed in 0.013456s, 74.3163 runs/s, 148.6326 assertions/s.
1) Failure:
TestTransaction#test_enum [enum.rb:44]:
Anticipated: "profitable"
Precise: nil
1 runs, 2 assertions, 1 failures, 0 errors, 0 skips
Okay, so the worth is appropriately set, nevertheless it disappears when save!
known as. After fast session with debugger I used to be in a position to determine that undesired change from "profitable"
to nil
occurs inside Enum::EnumType#deserialize technique. Generic ActiveModel::Sort::Worth
class which is a dad or mum for the ActiveRecord::Enum::EnumType
describes the aim of deserialize
technique like that:
Converts a worth from database enter to the suitable ruby sort. The return worth of this technique will probably be returned from
ActiveRecord::AttributeMethods::Learn#read_attribute
. The default implementation simply callsWorth#forged
.
Let’s have a have a look at our particular state of affairs:
# worth = "profitable"
# mapping = ActiveSupport::HashWithIndifferentAccess.new({
# "processing" => :processing,
# "failed" => :failed,
# "profitable" => :profitable,
# "canceled" => :canceled,
# })
# subtype = ActiveModel::Sort::String occasion
def deserialize(worth)
mapping.key(subtype.deserialize(worth))
finish
What precisely occurs:
subtype.deserialize(worth)
returns”profitable”
mapping
is requested to return a key for”profitable”
worth, however there isn’t any such within the hash, there’s aImage
:succesful
— yikesdeserialize("profitable")
returnsnil
as a substitute of"profitable"
In all probability a single line within the docs like: Don’t put symbols as values when defining the enum would do the job and save my time and probably many different confused builders. What’s much more puzzling is the truth that you’ll be able to present default worth as a Image
, you’ll be able to assign a worth which is a Image
and it will likely be saved as a String
, which is comprehensible, however the definition has to comprise string values — therefore the .transform_values(&:to_s)
trick.
Nevertheless, why use symbols to outline potential state?, it’s possible you’ll ask. As a result of it’s a requirement of aasm. State must be Image
or object responding to #identify
technique. If you happen to present String
, you’ll see good NoMethodError
as a result of it doesn’t reply to identify
.
what’s even funnier? Database returns String
when requested for Transactions#standing
. Serializing it to JSON can even flip it into String
as JSON doesn’t implement symbols. I may think about extra eventualities when fixed casting from Image
to String
forwards and backwards occur with none explicit purpose. However that’s precisely how third celebration gem (AASM) drives your architectural selections.
We are able to do even higher
If you happen to aren’t utilizing scopes, you’ll be able to merely disable them with scopes: false
.
Similar goes as an example strategies like succesful!
or failed?
, these may be disabled with instance_methods: false
.
Many of the fashions that was rewritten required each, however at any time when it was potential I disabled each of the options.
Occurrences of Mannequin.aasm.states.map(&:identify)
are replaceable by Mannequin.statuses.values
.
Mannequin.aasm.states_for_select
helper may be changed with Mannequin.statuses.values.map [I18n.l(name), value]
. Extract this to one in every of your helpers.
Possibly you don’t must name I18n
in any respect and easy humanize
will probably be sufficient.
Now it’s your flip.