rhosocial/yii2-base-models

View on GitHub
traits/SelfBlameableTrait.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

/**
 *  _   __ __ _____ _____ ___  ____  _____
 * | | / // // ___//_  _//   ||  __||_   _|
 * | |/ // /(__  )  / / / /| || |     | |
 * |___//_//____/  /_/ /_/ |_||_|     |_|
 * @link https://vistart.me/
 * @copyright Copyright (c) 2016 - 2023 vistart
 * @license https://vistart.me/license/
 */

namespace rhosocial\base\models\traits;

use Yii;
use yii\base\ModelEvent;
use yii\base\InvalidConfigException;
use yii\base\InvalidArgumentException;
use yii\db\ActiveQuery;
use yii\db\Exception;
use yii\db\IntegrityException;

/**
 * This trait is designed for the model who contains parent.
 *
 * The BlameableTrait use this trait by default. If you want to use this trait
 * into seperate model, please call the `initSelfBlameableEvents()` method in
 * `init()` method, like following:
 * ```php
 * public function init()
 * {
 *     $this->initSelfBlameableEvents();  // put it before parent call.
 *     parent::init();
 * }
 * ```
 *
 * The default reference ID attribute is `guid`. You can specify another attribute
 * in [[__construct()]] method.
 *
 * We strongly recommend you to set ancestor limit and children limit, and they
 * should not be too large.
 * The ancestor limit is preferably no more than 256, and children limit is no
 * more than 1024.
 * Too large number may seriously slow down the database response speed, especially
 * in updating operation.
 *
 * The data consistency between reference ID attribute and parent attribute can
 * only be ensured by my own. And update and delete operations should be placed
 * in the transaction to avoid data inconsistencies.
 * Even so, we cannot fully guarantee data consistency. Therefore, we provide a
 * method [[clearInvalidParent()]] for clearing non-existing parent node.
 *
 * @property static $parent
 * @property-read static[] $ancestors
 * @property-read string[] $ancestorChain
 * @property-read array $ancestorModels
 * @property-read static $commonAncestor
 * @property-read static[] $children
 * @property-read static[] $oldChildren
 * @property array $selfBlameableRules
 * @version 2.0
 * @since 1.0
 * @author vistart <i@vistart.me>
 */
trait SelfBlameableTrait
{

    /**
     * @var false|string attribute name of which store the parent's guid.
     * If you do not want to use self-blameable features, please set it false.
     * Or if you access any features of this trait when this parameter is false,
     * exception may be thrown.
     */
    public string|false $parentAttribute = false;

    /**
     * @var string|array rule name and parameters of parent attribute, as well
     * as self referenced ID attribute.
     */
    public string|array $parentAttributeRule = ['string', 'max' => 36];

    /**
     * @var string self referenced ID attribute.
     * If you enable self-blameable features, this parameter should be specified,
     * otherwise, exception will be thrown.
     */
    public string $refIdAttribute = 'guid';
    public static int $parentNone = 0;
    public static int $parentParent = 1;
    public static array $parentTypes = [
        0 => 'none',
        1 => 'parent',
    ];

    /**
     * @var ?string The constant determines the null parent.
     */
    public static ?string $nullParent = null;
    const SELF_REF_UPDATE_TYPE_ON_NO_ACTION = 0;
    const SELF_REF_UPDATE_TYPE_ON_RESTRICT = 1;
    const SELF_REF_UPDATE_TYPE_ON_CASCADE = 2;
    const SELF_REF_UPDATE_TYPE_ON_SET_NULL = 3;
    const SELF_REF_UPDATE_TYPES = [
        self::SELF_REF_UPDATE_TYPE_ON_NO_ACTION => 'on action',
        self::SELF_REF_UPDATE_TYPE_ON_RESTRICT => 'restrict',
        self::SELF_REF_UPDATE_TYPE_ON_CASCADE => 'cascade',
        self::SELF_REF_UPDATE_TYPE_ON_SET_NULL => 'set null',
    ];

    /**
     * @var integer indicates the on delete type. default to cascade.
     */
    public int $onDeleteType = self::SELF_REF_UPDATE_TYPE_ON_CASCADE;

    /**
     * @var integer indicates the on update type. default to cascade.
     */
    public int $onUpdateType = self::SELF_REF_UPDATE_TYPE_ON_CASCADE;

    /**
     * @var boolean indicates whether throw exception or not when restriction occurred on updating or deleting operation.
     */
    public bool $throwRestrictException = false;

    /**
     * @var array store the attribute validation rules.
     * If this field is a non-empty array, then it will be given.
     */
    private array $localSelfBlameableRules = [];
    const EVENT_PARENT_CHANGED = 'parentChanged';
    const EVENT_CHILD_ADDED = 'childAdded';

    /**
     * @var false|integer Set the limit of ancestor level. False is no limit.
     * We strongly recommend you set an unsigned integer which is less than 256.
     */
    public int|false|null $ancestorLimit = false;

    /**
     * @var false|int|null Set the limit of children (not descendants). False is no limit.
     * We strongly recommend you set an unsigned integer which is less than 1024.
     */
    public int|false|null $childrenLimit = false;

    /**
     * Get rules associated with self blamable attribute.
     * If self-blamable rules has been stored locally, then it will be given,
     * or return the parent attribute rule.
     * @return array rules.
     */
    public function getSelfBlameableRules(): array
    {
        if (!is_string($this->parentAttribute)) {
            return [];
        }
        if (!empty($this->localSelfBlameableRules) && is_array($this->localSelfBlameableRules)) {
            return $this->localSelfBlameableRules;
        }
        if (is_string($this->parentAttributeRule)) {
            $this->parentAttributeRule = [$this->parentAttributeRule];
        }
        $this->localSelfBlameableRules = [
            array_merge([$this->parentAttribute], $this->parentAttributeRule),
        ];
        return $this->localSelfBlameableRules;
    }

    /**
     * Set rules associated with self blamable attribute.
     * @param array $rules rules.
     */
    public function setSelfBlameableRules($rules = []): void
    {
        $this->localSelfBlameableRules = $rules;
    }

    /**
     * Check whether this model has reached the ancestor limit.
     * If $ancestorLimit is false, it will be regarded as no limit(return false).
     * If $ancestorLimit is not false and not an unsigned integer, 256 will be taken.
     * @return bool
     */
    public function hasReachedAncestorLimit(): bool
    {
        if ($this->ancestorLimit === false) {
            return false;
        }
        if (!is_numeric($this->ancestorLimit) || $this->ancestorLimit < 0) {
            $this->ancestorLimit = 256;
        }
        return count($this->getAncestorChain()) >= $this->ancestorLimit;
    }

    /**
     * Check whether this model has reached the children limit.
     * If $childrenLimit is false, it will be regarded as no limit(return false).
     * If $childrenLimit is not false and not an unsigned integer, 1024 will be taken.
     * @return bool
     */
    public function hasReachedChildrenLimit(): bool
    {
        if ($this->childrenLimit === false) {
            return false;
        }
        if (!is_numeric($this->childrenLimit) || $this->childrenLimit < 0) {
            $this->childrenLimit = 1024;
        }
        return ((int) $this->getChildren()->count()) >= $this->childrenLimit;
    }

    /**
     * Bear a child.
     * The creator of this child is not necessarily the creator of current one.
     * For example: Someone commit a comment on another user's comment, these
     * two comments are father and son, but do not belong to the same owner.
     * Therefore, you need to specify the creator of current model.
     * @param array $config
     * @return false|SelfBlameableTrait|null Null if reached the ancestor limit or children limit.
     * @throws InvalidConfigException Self reference ID attribute or
     * parent attribute not determined.
     */
    public function bear(array $config = []): false|static|null
    {
        if (!$this->parentAttribute) {
            throw new InvalidConfigException("Parent Attribute Not Determined.");
        }
        if (!$this->refIdAttribute) {
            throw new InvalidConfigException("Self Reference ID Attribute Not Determined.");
        }
        if ($this->hasReachedAncestorLimit()) {
            throw new InvalidArgumentException("Reached Ancestor Limit: " . $this->ancestorLimit);
        }
        if ($this->hasReachedChildrenLimit()) {
            throw new InvalidArgumentException("Reached Children Limit: ". $this->childrenLimit);
        }
        if (isset($config['class'])) {
            unset($config['class']);
        }
        $model = new static($config);
        if ($this->addChild($model) === false) {
            return false;
        }
        return $model;
    }

    /**
     * Add a child.
     * But if children limit reached, false will be given.
     * @param static $child
     * @return bool Whether adding child succeeded or not.
     */
    public function addChild($child): bool
    {
        return $this->hasReachedChildrenLimit() ? false : $child->setParent($this);
    }

    /**
     * Event triggered before deleting itself.
     * Note: DO NOT call it directly unless you know the consequences.
     * @param ModelEvent $event
     * @return bool true if parentAttribute not specified.
     * @throws IntegrityException|Exception throw if $throwRestrictException is true when $onDeleteType is on restrict.
     */
    public function onDeleteChildren($event): bool
    {
        $sender = $event->sender;
        /* @var $sender static */
        if (empty($sender->parentAttribute) || !is_string($sender->parentAttribute)) {
            return true;
        }
        switch ($sender->onDeleteType) {
            case self::SELF_REF_UPDATE_TYPE_ON_RESTRICT:
                $event->isValid = $sender->children === null;
                if ($sender->throwRestrictException) {
                    throw new IntegrityException('Delete restricted.');
                }
                break;
            case self::SELF_REF_UPDATE_TYPE_ON_CASCADE:
                $event->isValid = $sender->deleteChildren();
                break;
            case self::SELF_REF_UPDATE_TYPE_ON_SET_NULL:
                $event->isValid = $sender->updateChildren(null);
                break;
            case self::SELF_REF_UPDATE_TYPE_ON_NO_ACTION:
            default:
                $event->isValid = true;
                break;
        }
        return true;
    }

    /**
     * Event triggered before updating itself.
     * Note: DO NOT call it directly unless you know the consequences.
     * @param ModelEvent $event
     * @return bool true if parentAttribute not specified.
     * @throws IntegrityException|Exception throw if $throwRestrictException is true when $onUpdateType is on restrict.
     */
    public function onUpdateChildren($event): bool
    {
        $sender = $event->sender;
        /* @var $sender static */
        if (empty($sender->parentAttribute) || !is_string($sender->parentAttribute)) {
            return true;
        }
        switch ($sender->onUpdateType) {
            case self::SELF_REF_UPDATE_TYPE_ON_RESTRICT:
                $event->isValid = $sender->getOldChildren() === null;
                if ($sender->throwRestrictException) {
                    throw new IntegrityException('Update restricted.');
                }
                break;
            case self::SELF_REF_UPDATE_TYPE_ON_CASCADE:
                $event->isValid = $sender->updateChildren();
                break;
            case self::SELF_REF_UPDATE_TYPE_ON_SET_NULL:
                $event->isValid = $sender->updateChildren(null);
                break;
            case self::SELF_REF_UPDATE_TYPE_ON_NO_ACTION:
            default:
                $event->isValid = true;
                break;
        }
        return true;
    }

    /**
     * Get parent query.
     * Or get parent instance if access by magic property.
     * @return ActiveQuery
     */
    public function getParent()
    {
        return $this->hasOne(static::class, [$this->refIdAttribute => $this->parentAttribute]);
    }

    /**
     * Get parent ID.
     * @return string|null null if parent attribute isn't enabled.
     */
    public function getParentId(): ?string
    {
        return (is_string($this->parentAttribute) && !empty($this->parentAttribute)) ?
            $this->{$this->parentAttribute} : null;
    }

    /**
     * Set parent ID.
     * @param string $parentId
     * @return string|null null if parent attribute isn't enabled.
     */
    public function setParentId($parentId): ?string
    {
        return (is_string($this->parentAttribute) && !empty($this->parentAttribute)) ?
            $this->{$this->parentAttribute} = $parentId : null;
    }

    /**
     * Get reference ID.
     * @return ?string
     */
    public function getRefId(): ?string
    {
        if ($this->refIdAttribute == $this->guidAttribute) {
            return $this->getGUID();
        }
        if ($this->refIdAttribute == $this->idAttribute) {
            return $this->getID();
        }
        return $this->{$this->refIdAttribute};
    }

    /**
     * Set reference ID.
     * @param string $refId
     * @return string
     */
    public function setRefId($refId): string
    {
        if ($this->refIdAttribute == $this->guidAttribute) {
            return $this->setGUID($refId);
        }
        if ($this->refIdAttribute == $this->idAttribute) {
            return $this->setID($refId);
        }
        return $this->{$this->refIdAttribute} = $refId;
    }

    /**
     * Set parent.
     * Don't forget save model after setting it.
     * @param static $parent
     * @return false|string False if restriction reached. Otherwise parent's GUID given.
     */
    public function setParent($parent): false|string
    {
        if (empty($parent) || $this->getRefId() == $parent->getRefId() ||
            $parent->hasAncestor($this) || $this->hasReachedAncestorLimit()) {
            return false;
        }
        unset($this->parent);
        unset($parent->children);
        $this->trigger(self::EVENT_PARENT_CHANGED);
        $parent->trigger(self::EVENT_CHILD_ADDED);
        return $this->{$this->parentAttribute} = $parent->getRefId();
    }

    /**
     * Set null parent.
     * This method would unset the lazy loading records before setting it.
     * Don't forget save model after setting it.
     */
    public function setNullParent()
    {
        if ($this->hasParent()) {
            unset($this->parent->children);
        }
        unset($this->parent);
        $this->setParentId(static::$nullParent);
    }

    /**
     * Check whether this model has parent.
     * @return bool
     */
    public function hasParent(): bool
    {
        return $this->parent !== null;
    }

    /**
     * Check whether if $ancestor is the ancestor of myself.
     * Note: Itself will not be regarded as the ancestor.
     * @param static $ancestor
     * @return bool
     */
    public function hasAncestor($ancestor): bool
    {
        if (!$this->hasParent()) {
            return false;
        }
        if ($this->parent->getRefId() == $ancestor->getRefId()) {
            return true;
        }
        return $this->parent->hasAncestor($ancestor);
    }

    /**
     * Get ancestor chain. (Ancestors' GUID Only!)
     * If this model has ancestor, the return array consists all the ancestor in order.
     * The first element is parent, and the last element is root, otherwise return empty array.
     * If you want to get ancestor model, you can simplify instance a query and specify the
     * condition with the return value. But it will not return models under the order of ancestor chain.
     * @param string[] $ancestor
     * @return string[]
     */
    public function getAncestorChain($ancestor = []): array
    {
        if (!is_string($this->parentAttribute) || empty($this->parentAttribute)) {
            return [];
        }
        if (!$this->hasParent()) {
            return $ancestor;
        }
        $ancestor[] = $this->parent->getRefId();
        return $this->parent->getAncestorChain($ancestor);
    }

    /**
     * Get ancestors with specified ancestor chain.
     * @param string[] $ancestor Ancestor chain.
     * @return static[]|null
     */
    public static function getAncestorModels($ancestor)
    {
        if (empty($ancestor) || !is_array($ancestor)) {
            return [];
        }
        $models = [];
        foreach ($ancestor as $self) {
            $models[] = static::findOne($self);
        }
        return $models;
    }

    /**
     * Get ancestors.
     * @return static[]
     */
    public function getAncestors()
    {
        return static::getAncestorModels($this->getAncestorChain());
    }

    /**
     * Check whether if this model has common ancestor with $model.
     * @param static $model
     * @return boolean
     */
    public function hasCommonAncestor($model)
    {
        return $this->getCommonAncestor($model) !== null;
    }

    /**
     * Get common ancestor. If there isn't common ancestor, null will be given.
     * @param static $model
     * @return static
     */
    public function getCommonAncestor($model)
    {
        if (empty($this->parentAttribute) || !is_string($this->parentAttribute) ||
            empty($model) || !$model->hasParent()) {
            return null;
        }
        $ancestors = $this->getAncestorChain();
        if (in_array($model->parent->getRefId(), $ancestors)) {
            return $model->parent;
        }
        return $this->getCommonAncestor($model->parent);
    }

    /**
     * Get children query.
     * Or get children instances if access magic property.
     * @return ActiveQuery
     */
    public function getChildren()
    {
        return $this->hasMany(static::class, [$this->parentAttribute => $this->refIdAttribute])->inverseOf('parent');
    }

    /**
     * Get children which parent attribute point to old guid.
     * @return static[]
     */
    public function getOldChildren()
    {
        return static::find()->where([$this->parentAttribute => $this->getOldAttribute($this->refIdAttribute)])->all();
    }

    /**
     * Update all children, not grandchildren (descendants).
     * If onUpdateType is on cascade, the children will be updated automatically.
     * @param mixed $value set guid if false, set empty string if empty() return
     * true, otherwise set it to $parentAttribute.
     * @return IntegrityException|boolean true if all update operations
     * succeeded to execute, or false if anyone of them failed. If not production
     * environment or enable debug mode, it will return exception.
     * @throws IntegrityException|Exception throw if anyone update failed.
     * The exception message only contains the first error.
     */
    public function updateChildren($value = false)
    {
        $children = $this->getOldChildren();
        if (empty($children)) {
            return true;
        }
        $transaction = $this->getDb()->beginTransaction();
        try {
            foreach ($children as $child) {
                /* @var $child static */
                if ($value === false) {
                    $child->setParent($this);
                } elseif (empty($value)) {
                    $child->setNullParent();
                } else {
                    $child->setParentId($value);
                }
                if (!$child->save()) {
                    throw new IntegrityException('Update failed:', $child->getErrors());
                }
            }
            $transaction->commit();
        } catch (IntegrityException $ex) {
            $transaction->rollBack();
            if (YII_DEBUG || YII_ENV !== YII_ENV_PROD) {
                Yii::error($ex->getMessage(), __METHOD__);
                return $ex;
            }
            Yii::warning($ex->getMessage(), __METHOD__);
            return false;
        }
        return true;
    }

    /**
     * Delete all children, not grandchildren (descendants).
     * If onDeleteType is `on cascade`, the children will be deleted automatically.
     * If onDeleteType is `on restrict` and contains children, the deletion will
     * be restricted.
     * @return IntegrityException|boolean true if all delete operations
     * succeeded to execute, or false if anyone of them failed. If not production
     * environment or enable debug mode, it will return exception.
     * @throws IntegrityException|Exception throw if anyone delete failed.
     * The exception message only contains the first error.
     */
    public function deleteChildren()
    {
        $children = $this->children;
        if (empty($children)) {
            return true;
        }
        $transaction = $this->getDb()->beginTransaction();
        try {
            foreach ($children as $child) {
                if (!$child->delete()) {
                    throw new IntegrityException('Delete failed:', $child->getErrors());
                }
            }
            $transaction->commit();
        } catch (IntegrityException $ex) {
            $transaction->rollBack();
            if (YII_DEBUG || YII_ENV !== YII_ENV_PROD) {
                Yii::error($ex->getMessage(), __METHOD__);
                return $ex;
            }
            Yii::warning($ex->getMessage(), __METHOD__);
            return false;
        }
        return true;
    }

    /**
     * Update children's parent attribute.
     * Event triggered before updating.
     * @param ModelEvent $event
     * @return boolean
     */
    public function onParentRefIdChanged($event)
    {
        $sender = $event->sender;
        /* @var $sender static */
        if ($sender->isAttributeChanged($sender->refIdAttribute)) {
            return $sender->onUpdateChildren($event);
        }
    }

    /**
     * Attach events associated with self blameable attribute.
     */
    protected function initSelfBlameableEvents()
    {
        $this->on(static::EVENT_BEFORE_UPDATE, [$this, 'onParentRefIdChanged']);
        $this->on(static::EVENT_BEFORE_DELETE, [$this, 'onDeleteChildren']);
    }

    /**
     * Clear invalid parent.
     * The invalid state depends on which if parent id exists but it's corresponding
     * parent cannot be found.
     * @return bool True if parent attribute is set null, False if parent valid.
     */
    public function clearInvalidParent(): bool
    {
        if ($this->getParentId() !== static::$nullParent && !$this->hasParent()) {
            $this->setNullParent();
            return true;
        }
        return false;
    }
}