James McMahon
James McMahon

Reputation: 49639

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.

Upvotes: 22

Views: 6717

Answers (6)

AndyV
AndyV

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

Gjaldon
Gjaldon

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

SergeyKutsko
SergeyKutsko

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

Simone Carletti
Simone Carletti

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

Philip7899
Philip7899

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

Shaun Frost Duke Jackson
Shaun Frost Duke Jackson

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

Related Questions