Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Validation before persistance on state_machine gem

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.

like image 764
James McMahon Avatar asked Nov 23 '13 02:11

James McMahon


2 Answers

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.

like image 55
Simone Carletti Avatar answered Oct 07 '22 13:10

Simone Carletti


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.

like image 33
Gjaldon Avatar answered Oct 07 '22 12:10

Gjaldon