rhosocial/yii2-base-models

View on GitHub
traits/UserRelationTrait.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 rhosocial\base\models\models\BaseUserModel;
use rhosocial\base\models\traits\MultipleBlameableTrait as mb;
use yii\base\ModelEvent;
use yii\base\InvalidConfigException;
use yii\base\InvalidValueException;
use yii\db\Connection;
use yii\db\IntegrityException;

/**
 * Relation features.
 * This trait should be used in user relation model which is extended from
 * [[BaseBlameableModel]], and is specified `$hostClass` property. And the user
 * class should be extended from [[BaseUserModel]], or any other classes used
 * [[UserTrait]].
 * Notice: Several methods associated with "inserting", "updating" and "removing" may
 * involve more DB operations, I strongly recommend those methods to be placed in
 * transaction execution, in order to ensure data consistency.
 * If you want to use group feature, the class used [[UserRelationGroupTrait]]
 * must be used coordinately.
 * @property array $groupGuids the guid array of all groups which owned by current relation.
 * @property-read array $favoriteRules
 * @property boolean $isFavorite
 * @property-read static $opposite
 * @property-read array $otherGuidRules
 * @property string $remark
 * @property-read array $remarkRules
 * @property-read array $userRelationRules
 * @property-read mixed $group
 * @property-read array $groupMembers
 * @property-read array $allGroups
 * @property-read array $nonGroupMembers
 * @property-read integer $groupsCount
 * @property-read array $groupsRules
 *
 * @method mixed createGroup(BaseUserModel $user, array $config = [])
 * @method array|false addGroup(mixed $blame)
 * @method array|false addOrCreateGroup(mixed &$blame = null, BaseUserModel $user = null)
 * @method array|false removeGroup(mixed $blame)
 * @method array|false removeAllGroups()
 * @method mixed getGroup(string $blameGuid)
 * @method mixed getOrCreateGroup(string $blameGuid, BaseUserModel $user = null))
 * @method array getGroupMembers(mixed $blame) Get all members that belongs to
 * @method array getGroupGuids(bool $checkValid = false)
 * @method array|false setGroupGuids(array $guids = [], bool $checkValid = false)
 * @method array getOwnGroups() Get all groups that owned this relation.
 * @method array setOwnGroups(array $blames)
 * @method array isGroupContained(mixed $blame)
 * @method array getAllGroups() Get all groups created by whom created this relation.
 * @method array getNonGroupMembers(BaseUserModel $user) Get members that do not belong to any group.
 * @method integer getGroupsCount() Get the count of groups of this relation.
 * @method array getEmptyGroups() Get the groups which does not contain any relations.
 * @method array getGroupsRules() Get rules associated with group attribute.
 *
 * @version 2.0
 * @since 1.0
 * @author vistart <i@vistart.me>
 */
trait UserRelationTrait
{
    use mb,
        MutualTrait {
        mb::addBlame as addGroup;
        mb::createBlame as createGroup;
        mb::addOrCreateBlame as addOrCreateGroup;
        mb::removeBlame as removeGroup;
        mb::removeAllBlames as removeAllGroups;
        mb::getBlame as getGroup;
        mb::getOrCreateBlame as getOrCreateGroup;
        mb::getBlameds as getGroupMembers;
        mb::getBlameGuids as getGroupGuids;
        mb::setBlameGuids as setGroupGuids;
        mb::getOwnBlames as getOwnGroups;
        mb::setOwnBlames as setOwnGroups;
        mb::isBlameOwned as isGroupContained;
        mb::getAllBlames as getAllGroups;
        mb::getNonBlameds as getNonGroupMembers;
        mb::getBlamesCount as getGroupsCount;
        mb::getEmptyBlames as getEmptyGroups;
        mb::getMultipleBlameableAttributeRules as getGroupsRules;
    }

    /**
     * @var string|false
     */
    public string|false $remarkAttribute = 'remark';
    public static int $relationSingle = 0;
    public static int $relationMutual = 1;
    public int $relationType = 1;
    public static array $relationTypes = [
        0 => 'Single',
        1 => 'Mutual',
    ];

    /**
     * @var string|false the attribute name of which determines the relation type.
     */
    public string|false $mutualTypeAttribute = 'type';
    public static int $mutualTypeNormal = 0x00;
    public static int $mutualTypeSuspend = 0x01;

    /**
     * @var array Mutual types.
     */
    public static array $mutualTypes = [
        0x00 => 'Normal',
        0x01 => 'Suspend',
    ];

    /**
     * @var string|false the attribute name of which determines the `favorite` field.
     */
    public string|false $favoriteAttribute = 'favorite';

    /**
     * Permit to build self relation.
     * @var boolean
     */
    public bool $relationSelf = false;

    /**
     * Get whether this relation is favorite or not.
     * @return ?bool
     */
    public function getIsFavorite(): ?bool
    {
        $favoriteAttribute = $this->favoriteAttribute;
        return (is_string($favoriteAttribute) && !empty($favoriteAttribute)) ?
        (int) $this->$favoriteAttribute > 0 : null;
    }

    /**
     * Set favorite.
     * @param bool $fav
     */
    public function setIsFavorite(bool $fav): ?int
    {
        $favoriteAttribute = $this->favoriteAttribute;
        return (is_string($favoriteAttribute) && !empty($favoriteAttribute)) ?
        $this->$favoriteAttribute = ($fav ? 1 : 0) : null;
    }

    /**
     * @inheritdoc
     */
    public function rules()
    {
        return array_merge(parent::rules(), $this->getUserRelationRules());
    }

    /**
     * Validation rules associated with user relation.
     * @return array rules.
     */
    public function getUserRelationRules(): array
    {
        $rules = [];
        if ($this->relationType == static::$relationMutual) {
            $rules = [
                [[$this->mutualTypeAttribute], 'in', 'range' => array_keys(static::$mutualTypes)],
                [[$this->mutualTypeAttribute], 'default', 'value' => static::$mutualTypeNormal],
            ];
        }
        return array_merge($rules, $this->getRemarkRules(),
            $this->getFavoriteRules(),
            $this->getGroupsRules(),
        $this->getOtherGuidRules());
    }

    /**
     * Get remark.
     * @return ?string remark.
     */
    public function getRemark(): ?string
    {
        $remarkAttribute = $this->remarkAttribute;
        return (is_string($remarkAttribute) && !empty($remarkAttribute)) ? $this->$remarkAttribute : null;
    }

    /**
     * Set remark.
     * @param string $remark
     * @return ?string remark.
     */
    public function setRemark(string $remark): ?string
    {
        $remarkAttribute = $this->remarkAttribute;
        return (is_string($remarkAttribute) && !empty($remarkAttribute)) ? $this->$remarkAttribute = $remark : null;
    }

    /**
     * Validation rules associated with remark attribute.
     * @return array rules.
     */
    public function getRemarkRules(): array
    {
        return is_string($this->remarkAttribute) ? [
            [[$this->remarkAttribute], 'string'],
            [[$this->remarkAttribute], 'default', 'value' => ''],
            ] : [];
    }

    /**
     * Validation rules associated with favorites attribute.
     * @return array rules.
     */
    public function getFavoriteRules(): array
    {
        return is_string($this->favoriteAttribute) ? [
            [[$this->favoriteAttribute], 'boolean'],
            [[$this->favoriteAttribute], 'default', 'value' => 0],
            ] : [];
    }

    /**
     * Validation rules associated with other guid attribute.
     * @return array rules.
     */
    public function getOtherGuidRules(): array
    {
        return array_merge($this->getMutualRules(), [
            [[$this->otherGuidAttribute,
                $this->createdByAttribute],
                'unique',
                'targetAttribute' => [$this->otherGuidAttribute, $this->createdByAttribute]],
        ]);
    }

    /**
     * Attach events associated with user relation.
     */
    public function initUserRelationEvents(): void
    {
        $this->on(static::EVENT_INIT, [$this, 'onInitBlamesLimit']);
        $this->on(static::EVENT_NEW_RECORD_CREATED, [$this, 'onInitGroups']);
        $this->on(static::EVENT_NEW_RECORD_CREATED, [$this, 'onInitRemark']);
        $this->on(static::EVENT_MULTIPLE_BLAMES_CHANGED, [$this, 'onBlamesChanged']);
        $this->on(static::EVENT_AFTER_INSERT, [$this, 'onInsertRelation']);
        $this->on(static::EVENT_AFTER_UPDATE, [$this, 'onUpdateRelation']);
        $this->on(static::EVENT_AFTER_DELETE, [$this, 'onDeleteRelation']);
    }

    /**
     * Get opposite relation against self.
     * @return ?static
     */
    public function getOpposite(): ?static
    {
        if ($this->isNewRecord) {
            return null;
        }
        return static::find()->opposite($this->initiator, $this->recipient);
    }

    /**
     * Check whether the initiator is followed by recipient.
     * @param BaseUserModel $initiator
     * @param BaseUserModel $recipient
     * @return bool
     */
    public static function isFollowed($initiator, $recipient): bool
    {
        return static::find()->initiators($recipient)->recipients($initiator)->exists();
    }

    /**
     * Check whether the initiator is following recipient.
     * @param BaseUserModel $initiator
     * @param BaseUserModel $recipient
     * @return bool
     */
    public static function isFollowing($initiator, $recipient): bool
    {
        return static::find()->initiators($initiator)->recipients($recipient)->exists();
    }

    /**
     * Check whether the initiator is following and followed by recipient mutually (Single Relation).
     * Or check whether the initiator and recipient are friend whatever the mutual type is normal or suspend.
     * @param BaseUserModel $initiator
     * @param BaseUserModel $recipient
     * @return bool
     */
    public static function isMutual($initiator, $recipient): bool
    {
        return static::isFollowed($initiator, $recipient) && static::isFollowing($initiator, $recipient);
    }

    /**
     * Check whether the initiator is following and followed by recipient mutually (Single Relation).
     * Or check whether the initiator and recipient are friend if the mutual type is normal.
     * @param BaseUserModel $initiator
     * @param BaseUserModel $recipient
     * @return bool
     */
    public static function isFriend($initiator, $recipient): bool
    {
        $query = static::find();
        $model = $query->noInitModel;
        /* @var $model static */
        if ($model->relationType == static::$relationSingle) {
            return static::isMutual($initiator, $recipient);
        }
        if ($model->relationType == static::$relationMutual) {
            $relation = static::find()->initiators($initiator)->recipients($recipient)->
                    andWhere([$model->mutualTypeAttribute => static::$mutualTypeNormal])->exists();
            $inverse = static::find()->recipients($initiator)->initiators($recipient)->
                    andWhere([$model->mutualTypeAttribute => static::$mutualTypeNormal])->exists();
            return $relation && $inverse;
        }
        return false;
    }

    /**
     * Build new or return existed suspend mutual relation, or return null if
     * current type is not mutual.
     * @see buildRelation()
     * @param BaseUserModel|string $user Initiator or its GUID.
     * @param BaseUserModel|string $other Recipient or its GUID.
     * @return static The relation will be
     * given if exists, or return a new relation.
     */
    public static function buildSuspendRelation($user, $other)
    {
        $relation = static::buildRelation($user, $other);
        if (!$relation || $relation->relationType != static::$relationMutual) {
            return null;
        }
        $relation->setMutualType(static::$mutualTypeSuspend);
        return $relation;
    }

    /**
     * Build new or return existed normal relation.
     * The status of mutual relation will be changed to normal if it is not.
     * @see buildRelation()
     * @param BaseUserModel|string $user Initiator or its GUID.
     * @param BaseUserModel|string $other Recipient or its GUID.
     * @return static The relation will be
     * given if exists, or return a new relation.
     */
    public static function buildNormalRelation($user, $other)
    {
        $relation = static::buildRelation($user, $other);
        if (!$relation) {
            return null;
        }
        if ($relation->relationType == static::$relationMutual) {
            $relation->setMutualType(static::$mutualTypeNormal);
        }
        return $relation;
    }
    
    /**
     * Transform relation from suspend to normal.
     * Note: You should ensure the relation model is not new one.
     * @param static $relation
     * @return bool
     */
    public static function transformSuspendToNormal($relation): bool
    {
        if (!($relation instanceof static) || $relation->getIsNewRecord() ||
                $relation->relationType != static::$relationMutual) {
            return false;
        }
        $new = static::buildNormalRelation($relation->initiator, $relation->recipient);
        return $new->save() && $relation->refresh();
    }
    
    /**
     * Revert relation from normal to suspend.
     * Note: You should ensure the relation model is not new one.
     * @param static $relation
     * @return bool
     */
    public static function revertNormalToSuspend($relation): bool
    {
        if (!($relation instanceof static) || $relation->getIsNewRecord() ||
                $relation->relationType != static::$relationMutual) {
            return false;
        }
        $new = static::buildSuspendRelation($relation->initiator, $relation->recipient);
        return $new->save() && $relation->refresh();
    }

    /**
     * Build new or return existed relation between initiator and recipient.
     * If relation between initiator and recipient is not found, new relation will
     * be built. If initiator and recipient are the same one and it is not allowed
     * to build self relation, null will be given.
     * If you want to know whether the relation exists, you can check the return
     * value of `getIsNewRecord()` method.
     * @param BaseUserModel|string $user Initiator or its GUID.
     * @param BaseUserModel|string $other Recipient or its GUID.
     * @return static The relation will be
     * given if exists, or return a new relation. Or return null if not allowed
     * to build self relation,
     */
    protected static function buildRelation($user, $other)
    {
        $relationQuery = static::find()->initiators($user)->recipients($other);
        $noInit = $relationQuery->noInitModel;
        $relation = $relationQuery->one();
        if (!$relation) {
            if ($user instanceof BaseUserModel) {
                $user = $user->getGUID();
            }
            if ($other instanceof BaseUserModel) {
                $other = $other->getGUID();
            }
            if (!$noInit->relationSelf && $user == $other) {
                return null;
            }
            $relation = new static(['host' => $user, 'recipient' => $other]);
        }
        return $relation;
    }

    /**
     * Build opposite relation throughout the current relation. The opposite
     * relation will be given if existed.
     * @param static $relation
     * @return static
     */
    protected static function buildOppositeRelation($relation)
    {
        if (!$relation) {
            return null;
        }
        $opposite = static::buildRelation($relation->recipient, $relation->initiator);
        if ($relation->relationType == static::$relationSingle) {
            $opposite->relationType = static::$relationSingle;
        } elseif ($relation->relationType == static::$relationMutual) {
            $opposite->setMutualType($relation->getMutualType());
        }
        return $opposite;
    }
    
    /**
     * Get mutual type.
     * @return integer
     */
    public function getMutualType()
    {
        $btAttribute = $this->mutualTypeAttribute;
        if (is_string($btAttribute) && !empty($btAttribute)) {
            return $this->$btAttribute;
        }
        return static::$mutualTypeNormal;
    }
    
    /**
     * Set mutual type.
     * @param integer $type
     * @return integer
     */
    protected function setMutualType($type)
    {
        if (!array_key_exists($type, static::$mutualTypes)) {
            $type = static::$mutualTypeNormal;
        }
        $btAttribute = $this->mutualTypeAttribute;
        if (is_string($btAttribute) && !empty($btAttribute)) {
            return $this->$btAttribute = $type;
        }
        return static::$mutualTypeNormal;
    }
    
    /**
     * Insert relation, the process is placed in a transaction.
     * Note: This feature only support relational databases and skip all errors.
     * If you don't want to use transaction or database doesn't support it,
     * please use `save()` directly.
     * @param static $relation
     * @param ?Connection $connection
     * @return bool
     * @throws InvalidValueException
     * @throws InvalidConfigException
     * @throws IntegrityException
     */
    public static function insertRelation($relation, ?Connection $connection = null): bool
    {
        if (!($relation instanceof static)) {
            return false;
        }
        if (!$relation->getIsNewRecord()) {
            throw new InvalidValueException('This relation is not new one.');
        }
        if (!$connection && isset(\Yii::$app->db) && \Yii::$app->db instanceof Connection) {
            $connection = \Yii::$app->db;
        }
        if (!$connection) {
            throw new InvalidConfigException('Invalid database connection.');
        }
        /* @var $db Connection */
        $transaction = $connection->beginTransaction();
        try {
            if (!$relation->save()) {
                throw new IntegrityException('Relation insert failed.');
            }
            $transaction->commit();
        } catch (\Exception $ex) {
            $transaction->rollBack();
            return false;
        }
        return true;
    }
    
    /**
     * Remove relation, the process is placed in transaction.
     * Note: This feature only support relational databases and skip all errors.
     * If you don't want to use transaction or database doesn't support it,
     * please use `remove()` directly.
     * @param static $relation
     * @param Connection $connection
     * @return boolean|integer
     * @throws InvalidConfigException
     */
    public static function removeRelation($relation, Connection $connection = null)
    {
        if (!($relation instanceof static) || $relation->getIsNewRecord()) {
            return false;
        }
        
        if (!$connection && isset(\Yii::$app->db) && \Yii::$app->db instanceof Connection) {
            $connection = \Yii::$app->db;
        }
        if (!$connection) {
            throw new InvalidConfigException('Invalid database connection.');
        }
        /* @var $db Connection */
        $transaction = $connection->beginTransaction();
        try {
            $result = $relation->remove();
            $transaction->commit();
        } catch (\Exception $ex) {
            $transaction->rollBack();
            return false;
        }
        return $result;
    }

    /**
     * Remove myself.
     * @return integer|false The number of relations removed, or false if the remove
     * is unsuccessful for some reason. Note that it is possible the number of relations
     * removed is 0, even though the remove execution is successful.
     */
    public function remove()
    {
        return $this->delete();
    }

    /**
     * Remove first relation between initiator(s) and recipient(s).
     * @param BaseUserModel|string|array $user Initiator or its guid, or array of them.
     * @param BaseUserModel|string|array $other Recipient or its guid, or array of them.
     * @return integer|false The number of relations removed.
     */
    public static function removeOneRelation($user, $other)
    {
        $model = static::find()->initiators($user)->recipients($other)->one();
        if ($model instanceof static) {
            return $model->remove();
        }
        return false;
    }

    /**
     * Remove all relations between initiator(s) and recipient(s).
     * @param BaseUserModel|string|array $user Initiator or its guid, or array of them.
     * @param BaseUserModel|string|array $other Recipient or its guid, or array of them.
     * @return integer The number of relations removed.
     */
    public static function removeAllRelations($user, $other)
    {
        $rni = static::buildNoInitModel();
        return static::deleteAll([$rni->createdByAttribute => BaseUserModel::compositeGUIDs($user),
            $rni->otherGuidAttribute => BaseUserModel::compositeGUIDs($other)]);
    }

    /**
     * Get first relation between initiator(s) and recipient(s).
     * @param BaseUserModel|string|array $user Initiator or its guid, or array of them.
     * @param BaseUserModel|string|array $other Recipient or its guid, or array of them.
     * @return static
     */
    public static function findOneRelation($user, $other)
    {
        return static::find()->initiators($user)->recipients($other)->one();
    }

    /**
     * Get first opposite relation between initiator(s) and recipient(s).
     * @param BaseUserModel|string $user Initiator or its guid, or array of them.
     * @param BaseUserModel|string $other Recipient or its guid, or array of them.
     * @return static
     */
    public static function findOneOppositeRelation($user, $other)
    {
        return static::find()->initiators($other)->recipients($user)->one();
    }

    /**
     * Get user's or users' all relations, or by specified groups.
     * @param BaseUserModel|string|array $user Initiator or its GUID, or Initiators or their GUIDs.
     * @param BaseUserRelationGroupModel|string|array|null $groups UserRelationGroup
     * or its guid, or array of them. If you do not want to delimit the groups, please assign null.
     * @return array all eligible relations
     */
    public static function findOnesAllRelations($user, $groups = null)
    {
        return static::find()->initiators($user)->groups($groups)->all();
    }

    /**
     * Initialize groups attribute.
     * @param ModelEvent $event
     */
    public function onInitGroups($event): void
    {
        $sender = $event->sender;
        /* @var $sender static */
        $sender->removeAllGroups();
    }

    /**
     * Initialize remark attribute.
     * @param ModelEvent $event
     */
    public function onInitRemark($event): void
    {
        $sender = $event->sender;
        /* @var $sender static */
        $sender->setRemark('');
    }

    /**
     * The event triggered after insert new relation.
     * The opposite relation should be inserted without triggering events
     * simultaneously after new relation inserted,
     * @param ModelEvent $event
     * @throws IntegrityException throw if insert failed.
     */
    public function onInsertRelation($event): void
    {
        $sender = $event->sender;
        /* @var $sender static */
        if ($sender->relationType == static::$relationMutual) {
            $opposite = static::buildOppositeRelation($sender);
            $opposite->off(static::EVENT_AFTER_INSERT, [$opposite, 'onInsertRelation']);
            if (!$opposite->save()) {
                $opposite->recordWarnings();
                throw new IntegrityException('Reverse relation insert failed.');
            }
            $opposite->on(static::EVENT_AFTER_INSERT, [$opposite, 'onInsertRelation']);
        }
    }

    /**
     * The event triggered after update relation.
     * The opposite relation should be updated without triggering events
     * simultaneously after existed relation removed.
     * @param ModelEvent $event
     * @throws IntegrityException throw if update failed.
     */
    public function onUpdateRelation($event): void
    {
        $sender = $event->sender;
        /* @var $sender static */
        if ($sender->relationType == static::$relationMutual) {
            $opposite = static::buildOppositeRelation($sender);
            $opposite->off(static::EVENT_AFTER_UPDATE, [$opposite, 'onUpdateRelation']);
            if (!$opposite->save()) {
                $opposite->recordWarnings();
                throw new IntegrityException('Reverse relation update failed.');
            }
            $opposite->on(static::EVENT_AFTER_UPDATE, [$opposite, 'onUpdateRelation']);
        }
    }

    /**
     * The event triggered after delete relation.
     * The opposite relation should be deleted without triggering events
     * simultaneously after existed relation removed.
     * @param ModelEvent $event
     */
    public function onDeleteRelation($event): void
    {
        $sender = $event->sender;
        /* @var $sender static */
        if ($sender->relationType == static::$relationMutual) {
            $sender->off(static::EVENT_AFTER_DELETE, [$sender, 'onDeleteRelation']);
            static::removeAllRelations($sender->recipient, $sender->initiator);
            $sender->on(static::EVENT_AFTER_DELETE, [$sender, 'onDeleteRelation']);
        }
    }
}