Skip to content

Instantly share code, notes, and snippets.

@thinkingmedia
Created November 9, 2016 12:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thinkingmedia/8e0f39a72dfdea8ca51ced987c13764c to your computer and use it in GitHub Desktop.
Save thinkingmedia/8e0f39a72dfdea8ca51ced987c13764c to your computer and use it in GitHub Desktop.
A handler for ORM read/write operations
<?php
namespace Gems\Tools;
use Cake\Datasource\EntityInterface;
use Cake\Error\PHP7ErrorException;
use Cake\Log\Log;
use Cake\ORM\Table;
use Cake\Utility\Hash;
use Error;
use Gems\Exceptions\GemsArgumentException;
use Gems\Exceptions\GemsEntityException;
use Gems\Exceptions\GemsTransactionException;
/**
* Generic assert handlers.
*/
class GemsAssert
{
/**
* If there is a transaction in progress.
*
* @var bool
* @todo This won't work with multi-threading (future)
*/
private static $in_transaction = false;
/**
* Performs a database write operation and throws an exception if save fails.
*
* @param Table $table
* @param EntityInterface $entity
* @param array $options
* @throws GemsEntityException
*/
private static function _save(Table $table, EntityInterface $entity, array $options)
{
$create = $entity->isNew();
if (!$table->save($entity, $options)) {
$name = $table->alias();
$op = $create ? 'INSERT' : 'UPDATE';
throw new GemsEntityException("{$name} could not {$op} entity {$entity->id}", $entity);
}
}
/**
* Attempts to save changes to the table. If there is a MySQL deadlock error the save operation is repeated.
*
* @param Table $table
* @param EntityInterface $entity
* @param array $options
* @throws GemsEntityException
*/
private static function retrySave(Table $table, EntityInterface $entity, array $options = [])
{
$options = $options + ['atomic' => true];
if ($options['atomic'] === false) {
self::_save($table, $entity, $options);
} else {
self::transaction($table, function () use ($table, $entity, $options) {
self::_save($table, $entity, $options);
});
}
}
/**
* Throws an exception if the entity fails to be created.
*
* @param Table $table
* @param array $data
* @param null $callable
* @param array $options
* @return EntityInterface
* @throws GemsEntityException
*/
public static function create(Table $table, array $data, callable $callable = null, $options = [])
{
$result = true;
try {
$entity = $table->newEntity($data);
$result = $callable === null
? true
: call_user_func($callable, $entity);
} finally {
if ($result !== false) {
self::retrySave($table, $entity, $options);
}
}
return $result !== false
? $entity
: null;
}
/**
* @param Table $table
* @param EntityInterface $entity
* @param callable|null $callable
* @param array $options
* @throws GemsEntityException
*/
public static function delete(Table $table, EntityInterface $entity, callable $callable = null, $options = [])
{
$result = true;
try {
$result = $callable === null
? true
: call_user_func($callable, $entity);
} finally {
if (!$table->delete($entity, $options)) {
$name = $table->alias();
throw new GemsEntityException("{$name} could not delete entity.", $entity);
}
}
}
/**
* Returns a flat array describing all the errors in an entity.
*
* @param EntityInterface $entity
* @param string $format
* @return array
*/
public static function errors(EntityInterface $entity, $format = "%s: %s => %s")
{
$errors = [];
foreach (Hash::flatten($entity->errors(), '.') as $field => $message) {
$parts = explode('.', $field);
$ruleName = array_pop($parts);
$errors[] = sprintf($format, implode('.', $parts), $ruleName, $message);
}
return $errors;
}
/**
* Logs the errors of an entity.
*
* @param EntityInterface $entity
* @param int $indent
* @param string $format
*/
public static function logErrors(EntityInterface $entity, $indent = 3, $format = "%s: %s => %s")
{
foreach (self::errors($entity, $format) as $line) {
Log::error(GemsStrings::leftPad($line, $indent));
}
}
/**
* Throws an exception if the value is empty.
*
* @param mixed $value
* @param string $msg
* @return mixed
* @throws GemsArgumentException
*/
public static function notEmpty($value, $msg)
{
if (empty($value)) {
throw new GemsArgumentException($msg);
}
return $value;
}
/**
* Throws an exception if the entity fails to be saved.
*
* @param Table $table
* @param EntityInterface $entity
* @param array $options
* @param callable $callable
* @return EntityInterface|null
* @throws GemsEntityException
*/
public static function save(Table $table, EntityInterface $entity, callable $callable = null, $options = [])
{
$result = true;
try {
$result = $callable === null
? true
: $callable($entity);
} finally {
if ($entity->dirty() && $result !== false) {
self::retrySave($table, $entity, $options);
}
}
return $result !== false
? $entity
: null;
}
/**
* Executes the callback inside a transaction, and will repeat the callback if the transaction
* fails because of deadlock.
*
* @param Table $table
* @param callable $callable
* @return mixed
* @throws GemsTransactionException
* @throws \Exception
* @todo Can this be moved to a custom Connection that implements a retry on transactional?
*/
public static function transaction(Table $table, callable $callable)
{
if (self::$in_transaction === true) {
return $callable($table);
}
for ($retry = 0; $retry < 10; $retry++) {
try {
self::$in_transaction = true;
try {
return $table->connection()->transactional(function () use ($table, $callable) {
try {
return $callable($table);
} catch (Error $error) {
// fixes bug in Cake where it only catches Exception types.
// convert Error to Exception
throw new PHP7ErrorException($error);
}
});
} finally {
self::$in_transaction = false;
}
} catch (\PDOException $ex) {
if (GemsStrings::contains($ex->getMessage(), 'SQLSTATE[40001]')) {
Log::error($ex->getMessage());
sleep(100);
continue;
}
throw $ex;
}
}
throw new GemsTransactionException(sprintf("Failure to update [%s] increase retry count.", $table->alias()));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment