Skip to content

Instantly share code, notes, and snippets.

@mass6
Forked from tortuetorche/FiniteAuditTrailTrait.php
Created October 27, 2016 09:14
Show Gist options
  • Save mass6/3fb6eb1c57934d8b5111dad47b4297a3 to your computer and use it in GitHub Desktop.
Save mass6/3fb6eb1c57934d8b5111dad47b4297a3 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;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment