Reputation: 49639
What is the correct syntax for performing a validation on before a transition in the state_machine gem?
I've tried the following,
before_transition :apple => :orange do
validate :validate_core
end
def validate_core
if core.things.blank?
errors.add(:core, 'must have one thing')
end
end
But I get the following error,
undefined method `validate' for #<StateMachine::Machine:0x007ffed73e0bd8>
I've also tried writing it as,
state :orange do
validate :validate_core
end
But this causes a rollback after the record is saved, which is less than ideal. I'd like to stop the state machine from transitioning into :orange
in the first place.
The core problem is that in my controller I have logic that relies on the result of object.save
. The validation I have for my state machine doesn't kick in until after the initial save, so save gets returned as true and the controller goes on to logic it shouldn't hit if the object isn't valid.
I've worked around this by testing the validity manually in addition to checking the save, but it feels like there should be a way to have the validation fire before the object saves.
Upvotes: 22
Views: 6717
Reputation: 3741
The goal of "stop[ping] the state machine from transitioning into :orange in the first place" sounds like a guard on the transition. state_machine supports this with :if and :unless options on the transition definition. As with ActiveModel validators, the value for these options can be either a lambda or a symbol representing the method name to call on the object.
event :orangify
transition :apple => :orange, :if => lambda{|thing| thing.validate_core }
# OR transition :apple => :orange, :if => :validate_core
end
Upvotes: 0
Reputation: 5644
You could try to cancel the transition to the next state by doing something like this:
before_transition :apple => :orange do
if core.things.blank?
errors.add(:core, 'must have one thing')
throw :halt
end
end
This way, if core.things is blank, then an error would appear for core and the transition would be cancelled. I assume it also wouldn't make any changes to the DB. Haven't tried this code though but just read its source. Given that the code above, would likely lead to even more code to catch the exception, how about the approach below?
def orange_with_validation
if core.things.blank? && apple?
errors.add(:core, 'must have one thing')
else
#transition to orange state
orange
end
end
You could use the code above in places where you would like validation before it transitions to the orange state. This approach allows you to workaround the limitations of state_machine's callbacks. Using it in your controller which powers the wizard form would stop your form from moving to the next step due and would avoid any DB hits when it fails the validation.
Upvotes: 2
Reputation: 697
validate methos is class method of your model, so you can't call him from block that you pass to state_machine class method, because you have new context.
Try this:
YourModel < AR::B
validate :validate_core
state_machine :state, :initial => :some_state do
before_transition :apple => :orange do |model, transition|
model.valid?
end
end
def validate_core
if core.things.blank?
errors.add(:core, 'must have one thing')
end
end
end
Upvotes: 0
Reputation: 176412
The idea of that particular state machine is to embed validation declaration inside the state.
state :orange do
validate :validate_core
end
The configuration above will perform the validation :validate_core
whenever the object is transitioning to orange.
event :orangify do
transition all => :orange
end
I understand your concern about the rollback, but keep in mind that the rollback is performed in a transaction, thus it's quite cheap.
record.orangify!
Moreover, remember you can also use the non bang version that don't use exceptions.
> c.orangify
(0.3ms) BEGIN
(0.3ms) ROLLBACK
=> false
That said, if you want to use a different approach based on the before transition, then you only have to know that if the callback returns false, the transition is halted.
before_transition do
false
end
> c.orangify!
(0.2ms) BEGIN
(0.2ms) ROLLBACK
StateMachine::InvalidTransition: Cannot transition state via :cancel from :purchased (Reason(s): Transition halted)
Note that a transaction is always started, but it's likely no query will be performed if the callback is at the very beginning.
The before_transaction
accepts some params. You can yield the object and the transaction instance.
before_transition do |object, transaction|
object.validate_core
end
and indeed you can restrict it by event
before_transition all => :orange do |object, transaction|
object.validate_core # => false
end
In this case, validate_core
however is supposed to be a simple method that returns true/false. If you want to use the defined validation chain, then what comes to my mind is to invoke valid?
on the model itself.
before_transition all => :orange do |object, transaction|
object.valid?
end
However, please note that you can't run a transaction outside the scope of a transaction. In fact, if you inspect the code for perform
, you will see that callbacks are inside the transaction.
# Runs each of the collection's transitions in parallel.
#
# All transitions will run through the following steps:
# 1. Before callbacks
# 2. Persist state
# 3. Invoke action
# 4. After callbacks (if configured)
# 5. Rollback (if action is unsuccessful)
#
# If a block is passed to this method, that block will be called instead
# of invoking each transition's action.
def perform(&block)
reset
if valid?
if use_event_attributes? && !block_given?
each do |transition|
transition.transient = true
transition.machine.write(object, :event_transition, transition)
end
run_actions
else
within_transaction do
catch(:halt) { run_callbacks(&block) }
rollback unless success?
end
end
end
# ...
end
To skip the transaction, you should monkey patch state_machine so that transition methods (such as orangify!
) check whether the record is valid before transitioning.
Here's an example of what you should achieve
# Override orangify! state machine action
# If the record is valid, then perform the actual transition,
# otherwise return early.
def orangify!(*args)
return false unless self.valid?
super
end
Of course, you can't do that manually for each method, that's why you should monkey patch the library to achieve this result.
Upvotes: 31
Reputation: 4677
Rails is looking for a method 'validate' for state. But validate is an active record method. All of your models inherit from active record, but state does not, so it does not have a validate method. The way to get around this is to define a class method and call it in state. So lets say your model is called Fruit, you could have something like this
class Fruit < ActiveRecord::Base
def self.do_the_validation
validate :validate_core
end
before_transition :apple => :orange, :do => :do_the_validation
end
I'm not sure if you need self. Also, the second line may need to be:
self.validate :validate_core
I think this should work. That being said, is there any reason you're having it validate before the transition? Why not just put the validate by itself? It should always validate.
Upvotes: 0
Reputation: 1266
I'm still new but isn't is
validates
instead of
validate
http://edgeguides.rubyonrails.org/active_record_validations.html
Also just reading the documentation you have to do the validation in the state, I've never used state_machine but I think something like this:
state :orange do
validates_presence_of :apple
end
Upvotes: 0