Skip to content

Instantly share code, notes, and snippets.

@tortuetorche
Created July 12, 2016 15:32
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save tortuetorche/d5a317c8661af7db67396022117c14f3 to your computer and use it in GitHub Desktop.
Save tortuetorche/d5a317c8661af7db67396022117c14f3 to your computer and use it in GitHub Desktop.
FiniteStateMachine and FiniteAuditTrail Traits for Laravel 5.1 (WIP)
<?php namespace App\Finite;
/**
* The FiniteAuditTrail Trait.
* This plugin, for the Finite package (see https://github.com/yohang/Finite), adds support for keeping an audit trail for any state machine.
* Having an audit trail gives you a complete history of the state changes in your stateful model.
* Prerequisites:
* 1. Install Finite package (https://github.com/yohang/Finite#readme)
* 2. Use FiniteStateMachine in your model (https://gist.github.com/tortuetorche/6365575)
* Usage: in your Stateful Class use this trait after FiniteStateMachine trait, like this "use FiniteAuditTrailTrait;".
* Then call initAuditTrail() method at the end of initialization (__contruct() method) after initStateMachine() and parent::__construct() call.
* Finally create or complete the static boot() method in your model like this:
*
* class MyStatefulModel extends Model implements Finite\StatefulInterface
* {
*
* use App\Finite\FiniteStateMachineTrait;
* use App\Finite\FiniteAuditTrailTrait;
*
* public static function boot()
* {
* parent::boot();
* static::finiteAuditTrailBoot();
* }
*
* public function __construct($attributes = [])
* {
* $this->initStateMachine();
* parent::__construct($attributes);
* $this->initAuditTrail();
* }
* }
*
* Optionally in your AuditTrail model, you can create a $statefulModel property to access the StateMachine model who's audited:
*
* class MyStatefulModelStateTransition extends Model
* {
* public $statefulModel;
* }
*
* @author Tortue Torche <tortuetorche@spam.me>
*/
trait FiniteAuditTrailTrait
{
public static function finiteAuditTrailBoot()
{
static::saveInitialState();
}
protected static function saveInitialState()
{
static::created(function ($model) {
$transition = new \Finite\Transition\Transition(null, null, $model->findInitialState());
$model->storeAuditTrail($model, $transition, false);
});
}
protected $auditTrailModel;
protected $auditTrailName;
protected $auditTrailAttributes;// We can't set an empty array as default value here, maybe a PHP Trait bug ?
/**
* @param mixed|array|args $args
* if $args is an array:
* initAuditTrail(['to' => 'ModelAuditTrail', 'attributes' => ['name', 'email'], 'prepend' => true]);
* else: initAuditTrail('ModelAuditTrail', ['name', 'email']);
* first param: string $to Model name who stores the history
* second param: string|array $attributes Attribute(s) or method(s) from stateful model to save
*/
protected function initAuditTrail($args = null)
{
// Default options
$options = ['attributes' => (array) $this->auditTrailAttributes, 'to' => "\\".get_called_class()."StateTransition"];
if (func_num_args() === 2) {
$args = func_get_args();
list($options['to'], $attributes) = $args;
$newOptions = array_extract_options($attributes);
$options['attributes'] = $attributes;
$options = array_merge($options, $newOptions);
} elseif (func_num_args() === 1) {
if (is_array($args)) {
$newOptions = array_extract_options($args);
if (empty($newOptions)) {
$options['attributes'] = $args;
} else {
$options = array_merge($options, $newOptions);
}
} elseif (is_string($args)) {
$options['to'] = $args;
}
}
$this->auditTrailAttributes = (array) $options['attributes'];
$this->auditTrailName = $options['to'];
if (array_get($options, 'prepend') === true) {
// Audit trail State Machine changes at the first 'after' transition
$this->prependAfter([$this, 'storeAuditTrail']);
} else {
// Audit trail State Machine changes at the last 'after' transition
$this->addAfter([$this, 'storeAuditTrail']);
}
}
/**
* Create a new model instance that is existing.
*
* @param array $attributes
* @return \Illuminate\Database\Eloquent\Model|static
*/
public function newFromBuilder($attributes = [], $connection = null)
{
$instance = parent::newFromBuilder($attributes, $connection);
$this->restoreAuditTrail($instance);
return $instance;
}
/**
* @param \Illuminate\Database\Eloquent\Model|static $instance
*/
protected function restoreAuditTrail($instance)
{
// Initialize the StateMachine when the $instance is loaded from the database and not created via __construct() method
$instance->getStateMachine()->initialize();
}
/**
* @param object $self
* @param \Finite\Event\TransitionEvent|Finite\Transition\Transition $transitionEvent
* @param boolean $save Optional, default: true
*/
public function storeAuditTrail($self, $transitionEvent, $save = true)
{
// Save State Machine model to log initial state
if ($save === true || $this->exists === false) {
$this->save();
}
if (is_a($transitionEvent, "\Finite\Event\TransitionEvent")) {
$transition = $transitionEvent->getTransition();
} else {
$transition = $transitionEvent;
}
$this->auditTrailModel = app($this->auditTrailName);
if (property_exists($this->auditTrailModel, 'statefulModel')) {
$this->auditTrailModel->statefulModel = $this;
}
$values = [];
$values['event'] = $transition->getName();
$initialStates = $transition->getInitialStates();
if (! empty($initialStates)) {
$values['from'] = $transitionEvent->getInitialState()->getName();
}
$values['to'] = $transition->getState();
$statefulName = $this->auditTrailModel->statefulName ?: snake_case(str_singular($this->getTable()));
$values[$statefulName.'_id'] = $this->getKey();//Foreign key
$statefulType = $statefulName.'_type';
$columnNames = column_names($this->auditTrailModel->getTable());
if (in_array($statefulType, $columnNames)) {
$values[$statefulType] = get_classname($this);//For morph relation
}
// TODO: Fill and save additional attributes in a created()/afterCreate() model event
foreach ((array) $this->auditTrailAttributes as $attribute) {
if ($this->getAttribute($attribute)) {
$values[$attribute] = $this->getAttribute($attribute);
}
}
$this->auditTrailModel->fill($values);
$validated = $this->auditTrailModel->save();
if (! $validated) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException("Unable to save auditTrail model '".$this->auditTrailName."'");
}
}
public function getAuditTrailModel()
{
return $this->auditTrailModel;
}
}
<?php namespace App\Finite;
use Finite\StateMachine\StateMachine;
/**
* The FiniteStateMachine Trait.
* It provides easy ways to deal with Stateful objects and StateMachine
* Prerequisite: install Finite package (https://github.com/yohang/Finite#readme)
* Usage: in your Stateful Class, add the stateMachineConfig() protected method
* and call initStateMachine() method at initialization (__contruct() method)
*
* @author Tortue Torche <tortuetorche@spam.me>
*/
trait FiniteStateMachineTrait
{
/**
* @var \Finite\StateMachine\StateMachine
*/
protected $stateMachine;
/**
* @var array
*/
protected $finiteLoader;
/**
* @return array
*/
protected abstract function stateMachineConfig();
protected function initStateMachine(array $config = null)
{
$this->finiteLoader = $config ?: $this->stateMachineConfig();
$loader = new \Finite\Loader\ArrayLoader($this->finiteLoader);
$sm = new StateMachine($this);
$loader->load($sm);
$sm->initialize();
$this->stateMachine = $sm;
}
/**
* Sets the object state
*
* @param string $state
*/
public function setFiniteState($state)
{
$this->state = $state;
}
/**
* Get the object state
*
* @return string
*/
public function getFiniteState()
{
if ($this->state && is_object($this->state)) {
$this->state = (string) $this->state;
}
return $this->state;
}
/**
* @return \Finite\StateMachine\StateMachine
*/
public function getStateMachine()
{
return $this->stateMachine;
}
/**
* @return \Finite\State\State
*/
public function getCurrentState()
{
return $this->getStateMachine()->getCurrentState();
}
/**
* @return string
*/
public function getState()
{
return $this->getCurrentState()->getName();
}
/**
* @return string
*/
public function getHumanState()
{
return humanize($this->getState());
}
/**
* @param string $transitionName
*
* @return string|null
*/
public function getHumanStateTransition($transitionName)
{
$transitionIndex = array_search($transitionName, $this->getTransitions());
if ($transitionIndex !== null && $transitionIndex !== false) {
return humanize(array_get($this->getTransitions(), $transitionIndex));
}
}
/**
* Returns if this state is the initial state
*
* @return boolean
*/
public function isInitial()
{
return $this->getCurrentState()->isInitial();
}
/**
* Returns if this state is the final state
*
* @return mixed
*/
public function isFinal()
{
return $this->getCurrentState()->isFinal();
}
/**
* Returns if this state is a normal state (!($this->isInitial() || $this->isFinal())
*
* @return mixed
*/
public function isNormal()
{
return $this->getCurrentState()->isNormal();
}
/**
* Returns the state type
*
* @return string
*/
public function getType()
{
return $this->getCurrentState()->getType();
}
/**
* @return array<string>
*/
public function getTransitions()
{
return $this->getCurrentState()->getTransitions();
}
/**
* @return array<string>
*/
public function getProperties()
{
return $this->getCurrentState()->getProperties();
}
/**
* @param array $properties
*/
public function setProperties(array $properties)
{
$this->getCurrentState()->setProperties($properties);
}
/**
* @param string $property
*
* @return bool
*/
public function hasProperty($property)
{
return $this->getCurrentState()->has($property);
}
/**
* @param string $targetState
*
* @return bool
*/
public function is($targetState)
{
return $this->getState() === $targetState;
}
/**
* @param string $transitionName
*
* @return bool
*/
public function can($transitionName)
{
return $this->getStateMachine()->can($transitionName);
}
/**
* @param string $transitionName
*
* @return mixed
*
* @throws \Finite\Exception\StateException
*/
public function apply($transitionName)
{
return $this->getStateMachine()->apply($transitionName);
}
/**
* @param callable $callback
* @param array $spec
*/
public function addBefore($callback, array $spec = [])
{
$callbackHandler = new \Finite\Event\CallbackHandler($this->getStateMachine()->getDispatcher());
$callbackHandler->addBefore($this->getStateMachine(), $callback, $spec);
}
/**
* @param callable $callback
* @param array $spec
*/
public function addAfter($callback, array $spec = [])
{
$callbackHandler = new \Finite\Event\CallbackHandler($this->getStateMachine()->getDispatcher());
$callbackHandler->addAfter($this->getStateMachine(), $callback, $spec);
}
/**
* @param callable $callback
* @param array $spec
*/
public function prependBefore($callback, array $spec = [])
{
$config = $this->finiteLoader;
array_set($config, 'callbacks.before', array_merge(
[array_merge($spec, ['do' => $callback])],
array_get($config, 'callbacks.before', [])
));
$this->initStateMachine($config);
}
/**
* @param callable $callback
* @param array $spec
*/
public function prependAfter($callback, array $spec = [])
{
$config = $this->finiteLoader;
array_set($config, 'callbacks.after', array_merge(
[array_merge($spec, ['do' => $callback])],
array_get($config, 'callbacks.after', [])
));
$this->initStateMachine($config);
}
/**
* Find and return the Initial state if exists
*
* @return string
*
* @throws Exception\StateException
*/
public function findInitialState()
{
foreach (get_property($this->getStateMachine(), 'states') as $state) {
if (\Finite\State\State::TYPE_INITIAL === $state->getType()) {
return $state->getName();
}
}
throw new \Finite\Exception\StateException('No initial state found.');
}
/**
*
* @param string $attribute Attribute name who contains transition name
* @param string $errorMessage
* @return mixed Returns false if there are errors
*/
public function applyStateTransition($attribute = null, $errorMessage = null)
{
$attribute = $attribute ?: 'state_transition';
$attributes = $this->getAttributes();
if (($stateTransition = (string) array_get($attributes, $attribute))) {
if ($this->can($stateTransition)) {
unset($this->$attribute);
$this->apply($stateTransition);
} else {
$defaultErrorMessage = sprintf(
'The "%s" transition can not be applied to the "%s" state.',
$stateTransition,
$this->getState()
);
\Log::error($defaultErrorMessage);
$errorMessage = $errorMessage ?: $defaultErrorMessage;
if (method_exists($this, 'errors')) {
$this->errors()->add($attribute, $errorMessage);
} else {
throw new \Exception($errorMessage, 1);
}
return false;
}
}
}
public static function getStatesByProperty($propertyName, $propertyValue = null)
{
$staticContext = !(isset($this) && get_class($this) == __CLASS__);
if ($staticContext) {
$self = app(get_called_class());
} else {
$self = $this;
}
$states = array_get($self->stateMachineConfig(), 'states', []);
$matchedStates = [];
foreach ($states as $state => $options) {
$properties = array_get($options, 'properties', []);
foreach ($properties as $key => $value) {
if ($propertyName === $key && (! $propertyValue || $propertyValue === $value)) {
$matchedStates[] = $state;
break;
}
}
}
return $matchedStates;
}
public static function getStatesByType($type)
{
$staticContext = !(isset($this) && get_class($this) == __CLASS__);
if ($staticContext) {
$self = app(get_called_class());
} else {
$self = $this;
}
$states = array_get($self->stateMachineConfig(), 'states', []);
$matchedStates = [];
foreach ($states as $state => $options) {
if (array_get($options, 'type', null) === $type) {
$matchedStates[] = $state;
}
}
return $matchedStates;
}
}
@tortuetorche
Copy link
Author

FYI, original versions of these traits, for Laravel 4, are here:

@kelchm
Copy link

kelchm commented Nov 30, 2016

@tortuetorche, I've found that when the model's constructor is called, any properties which are loaded from the database are not yet assigned within the model instance (even after parent::__construct($attributes); is called). This means that the models state will always be the initial state, even when a different state has been persisted in the database.

I've not been able to find another place to hook into the model's lifecycle. Any thoughts?

@alexrosenfeld
Copy link

For anyone having issues with undefined functions, it seems the code is using helpers from AuthorityController, like array_extract_options, get_classname and get_property, which you'll need to have it in your code.

@mass6
Copy link

mass6 commented Jun 13, 2017

@kelchm - try overriding the NewFromBuilder method in your model, which will contain all model values and relations at that point. You can hook into it from there.

@wsenjer
Copy link

wsenjer commented May 1, 2019

For instances that were instantiated by Elequont Builder, you might need to override the newFromBuilder method in order to initiate the SM there as @mass6 mentioned.

<?php 
 public function newFromBuilder($attributes = [], $connection = null)
 {
		$model = parent::newFromBuilder($attributes, $connection);
		$model->initStateMachine();
		return $model;
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment