When it comes to ORM's, ActiveRecord is personal favourite of mine especially considering features such as AREL an AR Callbacks. In solving challenges, there's always multiple ways of skinning a cat, and here I will show you some interesting issues I've tackled today — and how I approached this from a troubleshooting aspect, and the steps I took literally in my head.
Come on Short Round, join me on a riveting adventure!
With great responsibility, comes great use of the around filter
Today I was troubleshooting an issue seen on a client's staging server, which pulls in Agents over an API. One particular issue was that emails were being sent out with invalid login credentials, when I was pretty sure those records were being persisted — even though I did not write the original implementation of this feature.
before_validation :generate_password
def generate_password
if self.password.nil?
self.password = SecureRandom.hex(4)
self.send_login_email
end
end
At first glance this seemed alright, but I later noticed that a bespoke create_or_update
method was being leveraged by relying on instance#update_attributes
, which will in fact save any instances of a #new_record?
. There was also an underlying issue due to the update nature as well, but I will come back to that.
As you may have figured out by now, this callback runs prior to validation, and the email is sent out without any confirmation of whether the instance was actually persisted to disk or not. One approach may consider use of ActiveModel::Dirty
but I decided to take a different route. One should also note that there is a presence validation on the password
attribute, so any new instances with an empty password would fail the call to #update_attributes
. It was really hard to get a sense of what was going on, even with the excellent pry
gem, so I decided that exceptions should be raised rather than failing silently and replaced the call with #update_attributes!
, which was a good move. This made me see exactly why instances were not being persisted.
I basically needed away to check that if a new password is generated for an Agent, and only then send out the email notification after persisting the agent. To the rescue we have the awesome around_filter
. This is simply a method that yields to a block, and the method's binding context is the record itself; the block is the actual save operation.
around_save :generate_password
private
def generate_password
return if self.persisted?
pass_generated = false
if self.new_record?
password = SecureRandom.hex(4)
self.password = password
self.password_confirmation = password
pass_generated = true
end
yield
send_login_email if pass_generated
end
I decided to also throw an assumption into the mix to keep this approach sane, and that is the DB should never contain any Agents with a nil
password attribute — which means that we are really only bothered about new Agents. This allowed me to easily update the password columns (for Devise) on a new instance of Agent
, evaluate the block, and then send the notification making sure that a new password was in fact generated.
However, you'll realise that our around_save
call would never be called as our #update_attributes
call will fail at the validation step. Why? We forgot to ensure the password attribute as non-nil during the validation stage. Let's tweak things a bit
before_validation :generate_password_for_new_record
around_save :generate_password
private
def generate_password_for_new_record
self.password = SecureRandom.hex(4) if self.new_record?
end
def generate_password
return if self.persisted?
pass_generated = false
if self.new_record?
password = SecureRandom.hex(4)
self.password = password
self.password_confirmation = password
pass_generated = true
end
yield
send_login_email if pass_generated
end
def send_login_email
if Rails.env.production? || non_production_agent_email?
Spree::MyAccount::AgentMailer.new(self.email, self.password).deliver
end
end
Pragmatic Matching, when you don't need a Match object
I'm sharing a particular trick I've come to use on many an occasion when I've found myself needing to verify if a Regex match has taken place, but really don't care for a Match object. Why burden the GC more than we really need to?
def send_login_email
if Rails.env.production? || non_production_agent_email?
Spree::MyAccount::AgentMailer.new(self.email, self.password).deliver
end
end
def non_production_agent_email?
self.email.match(/clientdomain.co.uk/i).length > 0
end
Whilst looking at this potential conundrum with my trusty crowbar, I noticed a NoMethodError
being raised on calling #length
on nil. It was clear that this is lacking some nil-guards.
Sure, we could also proceed with the approach of calling #match
but let's look at away to avoid that by attempting self.email =~ /clientdomain.co.uk/i
. This approach returns the index of where the match occurred or nil
and we can take advantage of that.
def non_production_agent_email?
if self.email && self.email.present?
return true if self.email =~ /clientdomain.co.uk/i
end
false
end
I also like using what I've come to called the short circuit
, where a method exits early on for a certain condition; if that condition is not met, the default behaviour follows — the predicate method #non_production_agent_email?
above returns false
by default, and true
only if our conditional is met.
However, as Antonis correctly pointed out, since we are in the context of Rails, there is no need for combining both a nil-guard and #present?
as, this returns false
on nil
.
It is also good practice to use double-negation for predicate methods as this inherently implies that we are returning a boolean rather than an object (and in Ruby, objects are truthy as well). So how do we convert and object to a boolean? Yup, break out the double-bang... you know you want to!
def non_production_agent_email?
!!(self.email.present? && self.email =~ /clientdomain.co.uk/i)
end
I hope you've enjoyed our little adventure and I plan on continuing this series of 'Master Classes' in future. Do tell me what you think in the comments below and if you'd like to discuss this further hit me up on Twitter (@bsodmike).
Contributions
Thanks to Antonis Tzorvas for pointing out an improvement for the #non_production_agent_email?
predicate method.