Callback chain management with attribute accessors
ActiveRecord callbacks like after_create, after_update etc can be used to abstract hooks related to creating or updating different objects that are logically affected by the object being saved. However being able to control a trigger inside a callback when deciding to stop or continue firing callbacks is important in some cases too.
Let's continue with our example domain of vehicles and wheels. A vehicle is an object that has many wheels. A vehicle is also an abstract class where automobiles, bicycles etc inherit.
class Vehicle < ActiveRecord::Base
has_many :wheels, dependent: :destroy
after_create :create_wheel
def count_wheels
wheels.count
end
private
def create_wheel
self.wheels.create(diameter: 30)
end
end
class Bicycle < Vehicle
def wheel_limit
2
end
end
class Wheel < ActiveRecord::Base
belongs_to :vehicle
after_create :create_other_wheel
private
def create_other_wheel
self.vehicle.wheels.create(diameter: self.diameter)
end
end
When a vehicle is created a default wheel is created through an after_create callback. No problem there. But if we imagine that we would like to also add another wheel on each wheel creation, this will trigger a chain of callbacks which we will surely like to finalize at some point.
Bicycle.create age: 2
At this point we get an error (obviously):
SystemStackError: stack level too deep
One solution to this is to use a skip_callback
statement just after the after_create
statement in the wheel model:
class Wheel < ActiveRecord::Base
belongs_to :vehicle
after_create :create_other_wheel
skip_callback :create, :after, :create_other_wheel, if: -> { self.vehicle.wheels.count >= self.vehicle.wheel_limit }
private
def create_other_wheel
self.vehicle.wheels.create(diameter: self.diameter)
end
end
Bicycle.create(age: 2).wheels.count
=> 2
This is not very DRY and personally I think it can be made better. Another solution is to explicitly turn off the callback trigger inside the callback, halting the chain:
class Wheel < ActiveRecord::Base
belongs_to :vehicle
after_create :create_other_wheel
private
def create_other_wheel
if self.vehicle.wheels.count >= self.vehicle.wheel_limit - 1 # skip next step
Wheel.skip_callback(:create, :after, :create_other_wheel)
self.vehicle.wheels.create(diameter: self.diameter)
Wheel.set_callback(:create, :after, :create_other_wheel) # enable the callback before return
else
self.vehicle.wheels.create(diameter: self.diameter)
end
end
end
Bicycle.create(age: 2).wheels.count
=> 2
The problems here are:
- This is not threadsafe as the callback is disabled and then enabled on class level
- You explicitly need to enable the callback back after you exit the chain which is just annoying.
The cleanest solution so far for this problem is to define a boolean flag using attr_accessor
and pass it into the instance just as it gets created, to either halt or continue the callback chain:
class Wheel < ActiveRecord::Base
attr_accessor :wheel_needed
belongs_to :vehicle
after_create :create_other_wheel, if: :wheel_needed
private
def create_other_wheel
self.vehicle.wheels.create(diameter: self.diameter, wheel_needed: self.vehicle.wheels.count + 1 < self.vehicle.wheel_limit)
end
end
This way, once we have enough wheels a boolean flag is set during the next chain link that determines whether we continue with another callback or not.
The only problem here is that when a vehicle is created and the after_create
callback for Vehicle is fired to create the first wheel, we have to also now supply a wheel_needed
attribute to start creating wheels, otherwise only a single wheel will be created as the if check above for create_other_wheel
will fail.
To overcome this the following minor change will suffice:
class Wheel < ActiveRecord::Base
attr_accessor :wheel_needed
belongs_to :vehicle
after_initialize :set_wheel_needed, if: -> { self.wheel_needed.nil? }
after_create :create_other_wheel, if: :wheel_needed
private
def set_wheel_needed
self.wheel_needed = self.vehicle.wheel_limit > 1 ? true : false
end
def create_other_wheel
self.vehicle.wheels.create(diameter: self.diameter, wheel_needed: self.vehicle.wheels.count + 1 < self.vehicle.wheel_limit)
end
end
Now we assign by default true as an initial value, if the wheel_limit
is larger than 1 (why not support unicycles?)