rhosocial/yii2-base-models

View on GitHub
traits/BlameableTrait.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\helpers\Number;
use rhosocial\base\models\queries\BaseUserQuery;
use yii\base\InvalidArgumentException;
use yii\base\ModelEvent;
use yii\base\NotSupportedException;
use yii\behaviors\BlameableBehavior;
use yii\caching\TagDependency;
use yii\data\Pagination;
use yii\db\ActiveQueryInterface;

/**
 * This trait is used for building blamable model. It contains following features:
 * 1.Single-column(field) content;
 * 2.Content type;
 * 3.Content rules(generated automatically);
 * 4.Creator(owner)'s GUID;
 * 5.Updater's GUID;
 * 6.Confirmation features, provided by [[ConfirmationTrait]];
 * 7.Self referenced features, provided by [[SelfBlameableTrait]];
 * @property-read array $blameableAttributeRules Get all rules associated with
 * blamable.
 * @property array $blameableRules Get or set all the rules associated with
 * creator, updater, content and its ID, as well as all the inherited rules.
 * @property array $blameableBehaviors Get or set all the behaviors associated
 * with creator and updater, as well as all the inherited behaviors.
 * @property-read array $descriptionRules Get description property rules.
 * @property-read mixed $content Content.
 * @property-read boolean $contentCanBeEdited Whether this content could be edited.
 * @property-read array $contentRules Get content rules.
 * @property BserUserModel $host The owner of this model.
 * @property BaseUserModel $user The owner of this model(the same as $host).
 * @property BaseUserModel $updater The updater who updated this model latest.
 * @version 2.0
 * @since 1.0
 * @author vistart <i@vistart.me>
 */
trait BlameableTrait
{
    use ConfirmationTrait,
        SelfBlameableTrait;

    private array|false $blameableLocalRules = [];
    private array|false $blameableLocalBehaviors = [];

    /**
     * @var false|string|array Specify the attribute(s) name of content(s). If
     * there is only one content attribute, you can assign its name. Or there
     * is multiple attributes associated with contents, you can assign their
     * names in array. If you don't want to use this feature, please assign
     * false.
     * For example:
     * ```php
     * public $contentAttribute = 'comment'; // only one field named as 'comment'.
     * ```
     * or
     * ```php
     * public $contentAttribute = ['year', 'month', 'day']; // multiple fields.
     * ```
     * or
     * ```php
     * public $contentAttribute = false; // no need of this feature.
     * ```
     * If you don't need this feature, you should add rules corresponding with
     * `content` in `rules()` method of your user model by yourself.
     */
    public string|array|false $contentAttribute = 'content';

    /**
     * @var array|string built-in validator name or validation method name and
     * additional parameters.
     */
    public array|string $contentAttributeRule = ['string', 'max' => 255];

    /**
     * @var false|string Specify the field which stores the type of content.
     */
    public string|false $contentTypeAttribute = false;

    /**
     * @var false|array Specify the logic type of content, not data type. If
     * your content doesn't need this feature. please specify false. If the
     * $contentAttribute is specified to false, this attribute will be skipped.
     * ```php
     * public $contentTypes = [
     *     'public',
     *     'private',
     *     'friend',
     * ];
     * ```
     */
    public array|false $contentTypes = false;

    /**
     * @var false|string This attribute specify the name of description
     * attribute. If this attribute is assigned to false, this feature will be
     * skipped.
     */
    public string|false $descriptionAttribute = false;

    /**
     * @var string
     */
    public string $initDescription = '';

    /**
     * @var string|false the attribute that will receive current user ID value. This
     * attribute must be assigned.
     */
    public string|false $createdByAttribute = "user_guid";

    /**
     * @var string|false the attribute that will receive current user ID value.
     * Set this property to false if you do not want to record the updater ID.
     */
    public string|false $updatedByAttribute = "user_guid";

    /**
     * @var boolean Add combined unique rule if assigned to true.
     */
    public bool $idCreatorCombinatedUnique = true;

    /**
     * @var boolean|string The name of user class which own the current entity.
     * If this attribute is assigned to false, this feature will be skipped, and
     * when you use create() method of UserTrait, it will be assigned with
     * current user class.
     */
    //public $userClass;

    /**
     * @var false|string Host class.
     */
    public string|false $hostClass;
    public static string $cacheKeyBlameableRules = 'blameable_rules';
    public static string $cacheTagBlameableRules = 'tag_blameable_rules';
    public static string $cacheKeyBlameableBehaviors = 'blameable_behaviors';
    public static string $cacheTagBlameableBehaviors = 'tag_blameable_behaviors';

    /**
     * @inheritdoc
     * ------------
     * The classical rules is like following:
     * [
     *     ['guid', 'required'],
     *     ['guid', 'unique'],
     *     ['guid', 'string', 'max' => 36],
     *
     *     ['id', 'required'],
     *     ['id', 'unique'],
     *     ['id', 'string', 'max' => 4],
     *
     *     ['created_at', 'safe'],
     *     ['updated_at', 'safe'],
     *
     *     ['ip_type', 'in', 'range' => [4, 6]],
     *     ['ip', 'number', 'integerOnly' => true, 'min' => 0],
     * ]
     * @return array
     */
    public function rules()
    {
        return $this->getBlameableRules();
    }

    /**
     * @inheritdoc
     */
    public function behaviors()
    {
        return $this->getBlameableBehaviors();
    }

    /**
     * Get total of contents which owned by their owner.
     * @return integer
     */
    public function countOfOwner()
    {
        $createdByAttribute = $this->createdByAttribute;
        return static::find()->where([$createdByAttribute => $this->$createdByAttribute])->count();
    }

    /**
     * Get content.
     * @return mixed
     */
    public function getContent()
    {
        $contentAttribute = $this->contentAttribute;
        if ($contentAttribute === false) {
            return null;
        }
        if (is_array($contentAttribute)) {
            $content = [];
            foreach ($contentAttribute as $key => $value) {
                $content[$key] = $this->$value;
            }
            return $content;
        }
        return $this->$contentAttribute;
    }

    /**
     * Set content.
     * @param mixed $content
     */
    public function setContent($content)
    {
        $contentAttribute = $this->contentAttribute;
        if ($contentAttribute === false) {
            return;
        }
        if (is_array($contentAttribute)) {
            foreach ($contentAttribute as $key => $value) {
                $this->$value = $content[$key];
            }
            return;
        }
        $this->$contentAttribute = $content;
    }

    /**
     * Determines whether content could be edited. Your should implement this
     * method by yourself.
     * @return boolean
     * @throws NotSupportedException
     */
    public function getContentCanBeEdited()
    {
        if ($this->contentAttribute === false) {
            return false;
        }
        throw new NotSupportedException("This method is not implemented.");
    }

    /**
     * Get blameable rules cache key.
     * @return string cache key.
     */
    public function getBlameableRulesCacheKey()
    {
        return static::class . $this->cachePrefix . static::$cacheKeyBlameableRules;
    }

    /**
     * Get blameable rules cache tag.
     * @return string cache tag
     */
    public function getBlameableRulesCacheTag()
    {
        return static::class . $this->cachePrefix . static::$cacheTagBlameableRules;
    }

    /**
     * Get the rules associated with content to be blamed.
     * @return array rules.
     */
    public function getBlameableRules()
    {
        $cache = $this->getCache();
        if ($cache) {
            $this->blameableLocalRules = $cache->get($this->getBlameableRulesCacheKey());
        }
        // 若当前规则不为空,且是数组,则认为是规则数组,直接返回。
        if (!empty($this->blameableLocalRules) && is_array($this->blameableLocalRules)) {
            return $this->blameableLocalRules;
        }

        // 父类规则与确认规则合并。
        if ($cache) {
            TagDependency::invalidate($cache, [$this->getEntityRulesCacheTag()]);
        }
        $rules = array_merge(
            parent::rules(),
            $this->getConfirmationRules(),
            $this->getBlameableAttributeRules(),
            $this->getDescriptionRules(),
            $this->getContentRules(),
            $this->getSelfBlameableRules()
        );
        $this->setBlameableRules($rules);
        return $this->blameableLocalRules;
    }

    /**
     * Get the rules associated with `createdByAttribute`, `updatedByAttribute`
     * and `idAttribute`-`createdByAttribute` combination unique.
     * @return array rules.
     * @throws NotSupportedException throws if `createdByAttribute` not set.
     */
    public function getBlameableAttributeRules()
    {
        $rules = [];
        // 创建者和上次修改者由 BlameableBehavior 负责,因此标记为安全。
        if (!is_string($this->createdByAttribute) || empty($this->createdByAttribute)) {
            throw new NotSupportedException('You must assign the creator.');
        }
        if ($this->guidAttribute != $this->createdByAttribute) {
            $rules[] = [
                [$this->createdByAttribute],
                'safe',
            ];
        }

        if (is_string($this->updatedByAttribute) && $this->guidAttribute != $this->updatedByAttribute && !empty($this->updatedByAttribute)) {
            $rules[] = [
                [$this->updatedByAttribute],
                'safe',
            ];
        }

        if ($this->idCreatorCombinatedUnique && is_string($this->idAttribute)) {
            $rules[] = [
                [$this->idAttribute,
                    $this->createdByAttribute],
                'unique',
                'targetAttribute' => [$this->idAttribute,
                    $this->createdByAttribute],
            ];
        }
        return $rules;
    }

    public function getIdRules(): array
    {
        if (!empty($this->idAttribute) && $this->idCreatorCombinatedUnique && $this->idAttributeType !== static::$idTypeAutoIncrement) {
            return [
                [[$this->idAttribute], 'required'],
            ];
        }
        return parent::getIdRules();
    }

    /**
     * Get the rules associated with `description` attribute.
     * @return array rules.
     */
    public function getDescriptionRules(): array
    {
        $rules = [];
        if (is_string($this->descriptionAttribute) && !empty($this->descriptionAttribute)) {
            $rules[] = [
                [$this->descriptionAttribute],
                'string'
            ];
            $rules[] = [
                [$this->descriptionAttribute],
                'default',
                'value' => $this->initDescription,
            ];
        }
        return $rules;
    }

    /**
     * Get the rules associated with `content` and `contentType` attributes.
     * @return array rules.
     */
    public function getContentRules(): array
    {
        if (!$this->contentAttribute) {
            return [];
        }
        $rules = [];
        $rules[] = [$this->contentAttribute, 'required'];
        if ($this->contentAttributeRule) {
            if (is_string($this->contentAttributeRule)) {
                $this->contentAttributeRule = [$this->contentAttributeRule];
            }
            if (is_array($this->contentAttributeRule)) {
                $rules[] = array_merge([$this->contentAttribute], $this->contentAttributeRule);
            }
        }

        if (!$this->contentTypeAttribute) {
            return $rules;
        }

        if (is_array($this->contentTypes) && !empty($this->contentTypes)) {
            $rules[] = [[
                $this->contentTypeAttribute],
                'required'];
            $rules[] = [[
                $this->contentTypeAttribute],
                'in',
                'range' => array_values($this->contentTypes)];
        }
        return $rules;
    }

    /**
     * Set blamable rules.
     * @param array $rules
     */
    protected function setBlameableRules($rules = []): void
    {
        $this->blameableLocalRules = $rules;
        $cache = $this->getCache();
        if ($cache) {
            $tagDependency = new TagDependency(['tags' => [$this->getBlameableRulesCacheTag()]]);
            $cache->set($this->getBlameableRulesCacheKey(), $rules, 0, $tagDependency);
        }
    }

    /**
     * Get blamable behaviors cache key.
     * @return string cache key.
     */
    public function getBlameableBehaviorsCacheKey(): string
    {
        return static::class . $this->cachePrefix . static::$cacheKeyBlameableBehaviors;
    }

    /**
     * Get blamable behaviors cache tag.
     * @return string cache tag.
     */
    public function getBlameableBehaviorsCacheTag(): string
    {
        return static::class . $this->cachePrefix . static::$cacheTagBlameableBehaviors;
    }

    /**
     * Get blamable behaviors. If current behaviors array is empty, the init
     * array will be given.
     * @return array
     */
    public function getBlameableBehaviors(): array
    {
        $cache = $this->getCache();
        if ($cache) {
            $this->blameableLocalBehaviors = $cache->get($this->getBlameableBehaviorsCacheKey());
        }
        if (empty($this->blameableLocalBehaviors) || !is_array($this->blameableLocalBehaviors)) {
            if ($cache) {
                TagDependency::invalidate($cache, [$this->getEntityBehaviorsCacheTag()]);
            }
            $behaviors = parent::behaviors();
            $behaviors['blameable'] = [
                'class' => BlameableBehavior::class,
                'createdByAttribute' => $this->createdByAttribute,
                'updatedByAttribute' => $this->updatedByAttribute,
                'value' => [$this,
                    'onGetCurrentUserGuid'],
            ];
            $this->setBlameableBehaviors($behaviors);
        }
        return $this->blameableLocalBehaviors;
    }

    /**
     * Set blamable behaviors.
     * @param array $behaviors
     */
    protected function setBlameableBehaviors(array $behaviors = []): void
    {
        $this->blameableLocalBehaviors = $behaviors;
        $cache = $this->getCache();
        if ($cache) {
            $tagDependencyConfig = ['tags' => [$this->getBlameableBehaviorsCacheTag()]];
            $tagDependency = new TagDependency($tagDependencyConfig);
            $cache->set($this->getBlameableBehaviorsCacheKey(), $behaviors, 0, $tagDependency);
        }
    }

    /**
     * Set description.
     * @return string|null description.
     */
    public function getDescription(): ?string
    {
        $descAttribute = $this->descriptionAttribute;
        return is_string($descAttribute) ? $this->$descAttribute : null;
    }

    /**
     * Get description.
     * @param string $desc description.
     * @return string|null description if enabled, or null if disabled.
     */
    public function setDescription(string $desc): ?string
    {
        $descAttribute = $this->descriptionAttribute;
        return is_string($descAttribute) ? $this->$descAttribute = $desc : null;
    }

    /**
     * Get blame who owned this blamable model.
     * NOTICE! This method will not check whether `$hostClass` exists. You should
     * specify it in `init()` method.
     * @return BaseUserQuery user.
     */
    public function getUser(): BaseUserQuery
    {
        return $this->getHost();
    }

    /**
     * Declares a `has-one` relation.
     * The declaration is returned in terms of a relational [[\yii\db\ActiveQuery]] instance
     * through which the related record can be queried and retrieved back.
     *
     * A `has-one` relation means that there is at most one related record matching
     * the criteria set by this relation, e.g., a customer has one country.
     *
     * For example, to declare the `country` relation for `Customer` class, we can write
     * the following code in the `Customer` class:
     *
     * ```php
     * public function getCountry()
     * {
     *     return $this->hasOne(Country::className(), ['id' => 'country_id']);
     * }
     * ```
     *
     * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name
     * in the related class `Country`, while the 'country_id' value refers to an attribute name
     * in the current AR class.
     *
     * Call methods declared in [[\yii\db\ActiveQuery]] to further customize the relation.
     *
     * This method is provided by [[\yii\db\BaseActiveRecord]].
     * @param string $class the class name of the related record
     * @param array $link the primary-foreign key constraint. The keys of the array refer to
     * the attributes of the record associated with the `$class` model, while the values of the
     * array refer to the corresponding attributes in **this** AR class.
     * @return ActiveQueryInterface the relational query object.
     */
    public abstract function hasOne($class, $link);

    /**
     * Get host of this model.
     * @return BaseUserQuery
     */
    public function getHost(): BaseUserQuery
    {
        $hostClass = $this->hostClass;
        $model = $hostClass::buildNoInitModel();
        return $this->hasOne($hostClass::className(), [$model->guidAttribute => $this->createdByAttribute]);
    }

    /**
     * Set host of this model.
     * @param mixed $host
     * @return string|boolean
     */
    public function setHost(mixed $host): bool|string
    {
        if ($host instanceof $this->hostClass || $host instanceof \yii\web\IdentityInterface) {
            return $this->{$this->createdByAttribute} = $host->getGUID();
        }
        if (is_string($host) && preg_match(Number::GUID_REGEX, $host)) {
            return $this->{$this->createdByAttribute} = $host;
        }
        if (is_string($host) && strlen($host) == 16) {
            return $this->{$this->createdByAttribute} = Number::guid(false, false, $host);
        }
        return false;
    }

    /**
     *
     * @param string|BaseUserModel $user
     * @return bool
     */
    public function setUser(string|BaseUserModel $user): bool
    {
        return $this->setHost($user);
    }

    /**
     * Get updater who updated this blamable model recently.
     * NOTICE! This method will not check whether `$hostClass` exists. You should
     * specify it in `init()` method.
     * @return BaseUserQuery|null user.
     */
    public function getUpdater(): ?BaseUserQuery
    {
        if (!is_string($this->updatedByAttribute) || empty($this->updatedByAttribute)) {
            return null;
        }
        $hostClass = $this->hostClass;
        $model = $hostClass::buildNoInitModel();
        /* @var $model BaseUserModel */
        return $this->hasOne($hostClass::className(), [$model->guidAttribute => $this->updatedByAttribute]);
    }

    /**
     * Set updater.
     * @param mixed $updater
     * @return bool|string
     */
    public function setUpdater(mixed $updater): bool|string
    {
        if (!is_string($this->updatedByAttribute) || empty($this->updatedByAttribute)) {
            return false;
        }
        if ($updater instanceof $this->hostClass || $updater instanceof \yii\web\IdentityInterface) {
            return $this->{$this->updatedByAttribute} = $updater->getGUID();
        }
        if (is_string($updater) && preg_match(Number::GUID_REGEX, $updater)) {
            return $this->{$this->updatedByAttribute} = $updater;
        }
        if (is_string($updater) && strlen($updater) == 16) {
            return $this->{$this->updatedByAttribute} = Number::guid(false, false, $updater);
        }
        return false;
    }

    /**
     * This event is triggered before the model update.
     * This method is ONLY used for being triggered by event. DO NOT call,
     * override or modify it directly, unless you know the consequences.
     * @param ModelEvent $event
     */
    public function onContentChanged($event)
    {
        $sender = $event->sender;
        /* @var $sender static */
        return $sender->resetConfirmation();
    }

    /**
     * Return the current user's GUID if current model doesn't specify the owner
     * yet, or return the owner's GUID if current model has been specified.
     * This method is ONLY used for being triggered by event. DO NOT call,
     * override or modify it directly, unless you know the consequences.
     * @param ModelEvent $event
     * @return string the GUID of current user or the owner.
     */
    public function onGetCurrentUserGuid($event): string
    {
        $sender = $event->sender;
        /* @var $sender static */
        if (isset($sender->attributes[$sender->createdByAttribute])) {
            return $sender->attributes[$sender->createdByAttribute];
        }
        $identity = \Yii::$app->user->identity;
        /* @var $identity BaseUserModel */
        if ($identity) {
            return $identity->getGUID();
        }
    }

    /**
     * Initialize type of content. the first of element[index is 0] of
     * $contentTypes will be used.
     * @param ModelEvent $event
     */
    public function onInitContentType($event): void
    {
        $sender = $event->sender;
        /* @var $sender static */
        if (!is_string($sender->contentTypeAttribute) || empty($sender->contentTypeAttribute)) {
            return;
        }
        $contentTypeAttribute = $sender->contentTypeAttribute;
        if (!isset($sender->$contentTypeAttribute) &&
            !empty($sender->contentTypes) &&
            is_array($sender->contentTypes)) {
            $sender->$contentTypeAttribute = $sender->contentTypes[array_key_first($sender->contentTypes)];
        }
    }

    /**
     * Initialize description property with $initDescription.
     * @param ModelEvent $event
     */
    public function onInitDescription($event): void
    {
        $sender = $event->sender;
        /* @var $sender static */
        if (!is_string($sender->descriptionAttribute) || empty($sender->descriptionAttribute)) {
            return;
        }
        $descriptionAttribute = $sender->descriptionAttribute;
        if (empty($sender->$descriptionAttribute)) {
            $sender->$descriptionAttribute = $sender->initDescription;
        }
    }

    /**
     * Attaches an event handler to an event.
     *
     * The event handler must be a valid PHP callback. The following are
     * some examples:
     *
     * ```
     * function ($event) { ... }         // anonymous function
     * [$object, 'handleClick']          // $object->handleClick()
     * ['Page', 'handleClick']           // Page::handleClick()
     * 'handleClick'                     // global function handleClick()
     * ```
     *
     * The event handler must be defined with the following signature,
     *
     * ```
     * function ($event)
     * ```
     *
     * where `$event` is an [[Event]] object which includes parameters associated with the event.
     *
     * This method is provided by [[\yii\base\Component]].
     * @param string $name the event name
     * @param callable $handler the event handler
     * @param mixed $data the data to be passed to the event handler when the event is triggered.
     * When the event handler is invoked, this data can be accessed via [[Event::data]].
     * @param boolean $append whether to append new event handler to the end of the existing
     * handler list. If false, the new handler will be inserted at the beginning of the existing
     * handler list.
     * @see off()
     */
    public abstract function on($name, $handler, $data = null, $append = true);

    /**
     * Detaches an existing event handler from this component.
     * This method is the opposite of [[on()]].
     * This method is provided by [[\yii\base\Component]]
     * @param string $name event name
     * @param callable $handler the event handler to be removed.
     * If it is null, all handlers attached to the named event will be removed.
     * @return boolean if a handler is found and detached
     * @see on()
     */
    public abstract function off($name, $handler = null);

    /**
     * Attach events associated with blameable model.
     */
    public function initBlameableEvents(): void
    {
        $this->on(self::EVENT_CONFIRMATION_CHANGED, [$this, "onConfirmationChanged"]);
        $this->on(static::EVENT_NEW_RECORD_CREATED, [$this, "onInitConfirmation"]);
        $contentTypeAttribute = $this->contentTypeAttribute;
        if (is_string($contentTypeAttribute) && !empty($contentTypeAttribute) && !isset($this->$contentTypeAttribute)) {
            $this->on(static::EVENT_NEW_RECORD_CREATED, [$this, "onInitContentType"]);
        }
        $descriptionAttribute = $this->descriptionAttribute;
        if (is_string($descriptionAttribute) && !empty($descriptionAttribute) && !isset($this->$descriptionAttribute)) {
            $this->on(static::EVENT_NEW_RECORD_CREATED, [$this, 'onInitDescription']);
        }
        $this->on(static::EVENT_BEFORE_UPDATE, [$this, "onContentChanged"]);
        $this->initSelfBlameableEvents();
    }

    /**
     * @inheritdoc
     */
    public function enabledFields(): array
    {
        $fields = parent::enabledFields();
        if (is_string($this->createdByAttribute) && !empty($this->createdByAttribute)) {
            $fields[] = $this->createdByAttribute;
        }
        if (is_string($this->updatedByAttribute) && !empty($this->updatedByAttribute) &&
            $this->createdByAttribute != $this->updatedByAttribute) {
            $fields[] = $this->updatedByAttribute;
        }
        if (is_string($this->contentAttribute)) {
            $fields[] = $this->contentAttribute;
        }
        if (is_array($this->contentAttribute)) {
            $fields = array_merge($fields, $this->contentAttribute);
        }
        if (is_string($this->descriptionAttribute)) {
            $fields[] = $this->descriptionAttribute;
        }
        if (is_string($this->confirmationAttribute)) {
            $fields[] = $this->confirmationAttribute;
        }
        if (is_string($this->parentAttribute)) {
            $fields[] = $this->parentAttribute;
        }
        return $fields;
    }

    /**
     * Find all follows by specified identity. If `$identity` is null, the logged-in
     * identity will be taken.
     * @param string|int|null $pageSize If it is 'all`, then will find all follows,
     * the `$currentPage` parameter will be skipped. If it is integer, it will be
     * regarded as sum of models in one page.
     * @param integer $currentPage The current page number, begun with 0.
     * @param mixed $identity It's type depends on {$this->hostClass}.
     * @return static[] If no follows, null will be given, or return follow array.
     */
    public static function findAllByIdentityInBatch(string|int|null $pageSize = 'all', $currentPage = 0, $identity = null)
    {
        if ($pageSize === 'all') {
            return static::findByIdentity($identity)->all();
        }
        return static::findByIdentity($identity)->page($pageSize, $currentPage)->all();
    }

    /**
     * Find one follow by specified identity. If `$identity` is null, the logged-in
     * identity will be taken. If $identity doesn't have the follower, null will
     * be given.
     * @param int|string|null $id user id.
     * @param bool $throwException
     * @param mixed|null $identity It's type depends on {$this->hostClass}.
     * @return ?static
     * @throws InvalidArgumentException
     */
    public static function findOneById(int|string|null $id, bool $throwException = true, mixed $identity = null): ?static
    {
        $query = static::findByIdentity($identity);
        if (!empty($id)) {
            $query = $query->id($id);
        }
        $model = $query->one();
        if (!$model && $throwException) {
            throw new InvalidArgumentException('Model Not Found.');
        }
        return $model;
    }

    /**
     * Get total of follows of specified identity.
     * @param mixed|null $identity It's type depends on {$this->hostClass}.
     * @return int total.
     */
    public static function countByIdentity(mixed $identity = null): int
    {
        return (int)(static::findByIdentity($identity)->count());
    }

    /**
     * Get pagination, used for building contents page by page.
     * @param int $limit
     * @param mixed|null $identity It's type depends on {$this->hostClass}.
     * @return Pagination
     */
    public static function getPagination(int $limit = 10, mixed $identity = null): Pagination
    {
        $limit = (int) $limit;
        $count = static::countByIdentity($identity);
        if ($limit > $count) {
            $limit = $count;
        }
        return new Pagination(['totalCount' => $count, 'pageSize' => $limit]);
    }
}