<?php

namespace Arrilot\BitrixModels\Models;

use Arrilot\BitrixModels\Exceptions\ExceptionFromBitrix;
use Arrilot\BitrixModels\Queries\BaseQuery;
use LogicException;

abstract class BitrixModel extends BaseBitrixModel
{
    /**
     * Bitrix entity object.
     *
     * @var object
     */
    public static $bxObject;

    /**
     * Corresponding object class name.
     *
     * @var string
     */
    protected static $objectClass = '';

    /**
     * Fetch method and parameters.
     *
     * @var array|string
     */
    public static $fetchUsing = [
        'method' => 'Fetch',
        'params' => [],
    ];

    /**
     * Constructor.
     *
     * @param $id
     * @param $fields
     */
    public function __construct($id = null, $fields = null)
    {
        static::instantiateObject();

        $this->id = $id;

        $this->fill($fields);
    }

    /**
     * Activate model.
     *
     * @return bool
     */
    public function activate()
    {
        $this->fields['ACTIVE'] = 'Y';

        return $this->save(['ACTIVE']);
    }

    /**
     * Deactivate model.
     *
     * @return bool
     */
    public function deactivate()
    {
        $this->fields['ACTIVE'] = 'N';

        return $this->save(['ACTIVE']);
    }

    /**
     * Internal part of create to avoid problems with static and inheritance
     *
     * @param $fields
     *
     * @throws ExceptionFromBitrix
     *
     * @return static|bool
     */
    protected static function internalCreate($fields)
    {
        $model = new static(null, $fields);

        if ($model->onBeforeSave() === false || $model->onBeforeCreate() === false) {
            return false;
        }

        $bxObject = static::instantiateObject();
        $id = static::internalDirectCreate($bxObject, $model->fields);
        $model->setId($id);

        $result = $id ? true : false;

        $model->setEventErrorsOnFail($result, $bxObject);
        $model->onAfterCreate($result);
        $model->onAfterSave($result);
        $model->resetEventErrors();
        $model->throwExceptionOnFail($result, $bxObject);

        return $model;
    }

    public static function internalDirectCreate($bxObject, $fields)
    {
        return $bxObject->add($fields);
    }

    /**
     * Delete model.
     *
     * @return bool
     * @throws ExceptionFromBitrix
     */
    public function delete()
    {
        if ($this->onBeforeDelete() === false) {
            return false;
        }

        $result = static::$bxObject->delete($this->id);

        $this->setEventErrorsOnFail($result, static::$bxObject);
        $this->onAfterDelete($result);
        $this->resetEventErrors();
        $this->throwExceptionOnFail($result, static::$bxObject);

        return $result;
    }

    /**
     * Save model to database.
     *
     * @param array $selectedFields save only these fields instead of all.
     * @return bool
     * @throws ExceptionFromBitrix
     */
    public function save($selectedFields = [])
    {
        $fieldsSelectedForSave = is_array($selectedFields) ? $selectedFields : func_get_args();
        $this->fieldsSelectedForSave = $fieldsSelectedForSave;
        if ($this->onBeforeSave() === false || $this->onBeforeUpdate() === false) {
            $this->fieldsSelectedForSave = [];
            return false;
        } else {
            $this->fieldsSelectedForSave = [];
        }

        $fields = $this->normalizeFieldsForSave($fieldsSelectedForSave);
        $result = $fields === null
            ? true
            : $this->internalUpdate($fields, $fieldsSelectedForSave);

        $this->setEventErrorsOnFail($result, static::$bxObject);
        $this->onAfterUpdate($result);
        $this->onAfterSave($result);
        $this->resetEventErrors();
        $this->throwExceptionOnFail($result, static::$bxObject);

        return $result;
    }

    /**
     * @param $fields
     * @param $fieldsSelectedForSave
     * @return bool
     */
    protected function internalUpdate($fields, $fieldsSelectedForSave)
    {
        return !empty($fields) ? static::$bxObject->update($this->id, $fields) : false;
    }

    /**
     * Scope to get only active items.
     *
     * @param BaseQuery $query
     *
     * @return BaseQuery
     */
    public function scopeActive($query)
    {
        $query->filter['ACTIVE'] = 'Y';

        return $query;
    }

    /**
     * Determine whether the field should be stopped from passing to "update".
     *
     * @param string $field
     * @param mixed  $value
     * @param array  $selectedFields
     *
     * @return bool
     */
    protected function fieldShouldNotBeSaved($field, $value, $selectedFields)
    {
        $blacklistedFields = [
            'ID',
            'IBLOCK_ID',
            'GROUPS',
        ];

        return (!empty($selectedFields) && !in_array($field, $selectedFields))
            || in_array($field, $blacklistedFields)
            || ($field[0] === '~')
            || (substr($field, 0, 9) === 'PROPERTY_')
            || (is_array($this->original) && array_key_exists($field, $this->original) && $this->original[$field] === $value);
    }

    /**
     * Instantiate bitrix entity object.
     *
     * @throws LogicException
     *
     * @return object
     */
    public static function instantiateObject()
    {
        if (static::$bxObject) {
            return static::$bxObject;
        }

        if (class_exists(static::$objectClass)) {
            return static::$bxObject = new static::$objectClass();
        }

        throw new LogicException('Object initialization failed');
    }

    /**
     * Destroy bitrix entity object.
     *
     * @return void
     */
    public static function destroyObject()
    {
        static::$bxObject = null;
    }

    /**
     * Set eventErrors field on error.
     *
     * @param bool $result
     * @param object $bxObject
     */
    protected function setEventErrorsOnFail($result, $bxObject)
    {
        if (!$result) {
            $this->eventErrors = (array) $bxObject->LAST_ERROR;
        }
    }

    /**
     * Throw bitrix exception on fail
     *
     * @param bool $result
     * @param object $bxObject
     * @throws ExceptionFromBitrix
     */
    protected function throwExceptionOnFail($result, $bxObject)
    {
        if (!$result) {
            throw new ExceptionFromBitrix($bxObject->LAST_ERROR);
        }
    }
}


<?php

namespace Arrilot\BitrixModels\Queries;

use Arrilot\BitrixModels\Helpers;
use Arrilot\BitrixModels\Models\BaseBitrixModel;
use Illuminate\Support\Collection;

/**
 * BaseRelationQuery содержит основные методы и свойства для загрузки релейшенов
 *
 * @method BaseBitrixModel first()
 * @method Collection|BaseBitrixModel[] getList()
 * @property array $select
 */
trait BaseRelationQuery
{
    /**
     * @var bool - когда запрос представляет связь с один-ко-многим. Если true, вернуться все найденные модели, иначе только первая
     */
    public $multiple;
    /**
     * @var string - настройка связи моделей. ключ_у_связанной_модели
     */
    public $foreignKey;
    /**
     * @var string - настройка связи моделей. ключ_у_текущей_модели
     */
    public $localKey;
    /**
     * @var BaseBitrixModel - модель, для которой производится загрузка релейшена
     */
    public $primaryModel;
    /**
     * @var array - список связей, которые должны быть подгружены при выполнении запроса
     */
    public $with;

    /**
     * Найти связанные записи для определенной модели [[$this->primaryModel]]
     * Этот метод вызывается когда релейшн вызывается ленивой загрузкой $model->relation
     * @return Collection|BaseBitrixModel[]|BaseBitrixModel - связанные модели
     * @throws \Exception
     */
    public function findFor()
    {
        return $this->multiple ? $this->getList() : $this->first();
    }

    /**
     * Определяет связи, которые должны быть загружены при выполнении запроса
     *
     * Передавая массив можно указать ключем - название релейшена, а значением - коллбек для кастомизации запроса
     *
     * @param array|string $with - связи, которые необходимо жадно подгрузить
     *  // Загрузить Customer и сразу для каждой модели подгрузить orders и country
     * Customer::query()->with(['orders', 'country'])->getList();
     *
     *  // Загрузить Customer и сразу для каждой модели подгрузить orders, а также для orders загрузить address
     * Customer::find()->with('orders.address')->getList();
     *
     *  // Загрузить Customer и сразу для каждой модели подгрузить country и orders (только активные)
     * Customer::find()->with([
     *     'orders' => function (BaseQuery $query) {
     *         $query->filter(['ACTIVE' => 'Y']);
     *     },
     *     'country',
     * ])->all();
     *
     * @return $this
     */
    public function with($with)
    {
        $with = is_string($with) ? func_get_args() : $with;

        if (empty($this->with)) {
            $this->with = $with;
        } elseif (!empty($with)) {
            foreach ($with as $name => $value) {
                if (is_int($name)) {
                    // дубликаты связей будут устранены в normalizeRelations()
                    $this->with[] = $value;
                } else {
                    $this->with[$name] = $value;
                }
            }
        }

        return $this;
    }

    /**
     * Добавить фильтр для загрзуки связи относительно моделей
     * @param Collection|BaseBitrixModel[] $models
     */
    protected function filterByModels($models)
    {
        $values = [];
        foreach ($models as $model) {
            if (($value = $model[$this->foreignKey]) !== null) {
                if (is_array($value)) {
                    $values = array_merge($values, $value);
                } else {
                    $values[] = $value;
                }
            }
        }

        $values = array_filter($values);
        if (empty($values)) {
            $this->stopQuery();
        }

        $primary = $this->localKey;
        if (preg_match('/^PROPERTY_(.*)_VALUE$/', $primary, $matches) && !empty($matches[1])) {
            $primary = 'PROPERTY_' . $matches[1];
        }
        $values = array_unique($values, SORT_REGULAR);
        if (count($values) == 1) {
            $values = current($values);
        } else {
            $this->prepareMultiFilter($primary, $values);
        }

        $this->filter([$primary => $values]);
        $this->select[] = $primary;
    }

    /**
     * Подгрузить связанные модели для уже загруденных моделей
     * @param array $with - массив релейшенов, которые необходимо подгрузить
     * @param Collection|BaseBitrixModel[] $models модели, для которых загружать связи
     */
    public function findWith($with, &$models)
    {
        // --- получаем модель, на основании которой будем брать запросы релейшенов
        $primaryModel = $models->first();
        if (!$primaryModel instanceof BaseBitrixModel) {
            $primaryModel = $this->model;
        }

        $relations = $this->normalizeRelations($primaryModel, $with);
        /* @var $relation BaseQuery */
        foreach ($relations as $name => $relation) {
            $relation->populateRelation($name, $models);
        }
    }

    /**
     * @param BaseBitrixModel $model - модель пустышка, чтобы получить запросы
     * @param array $with
     * @return BaseQuery[]
     */
    private function normalizeRelations($model, $with)
    {
        $relations = [];
        foreach ($with as $name => $callback) {
            if (is_int($name)) { // Если ключ - число, значит в значении написано название релейшена
                $name = $callback;
                $callback = null;
            }

            if (($pos = strpos($name, '.')) !== false) { // Если есть точка, значит указан вложенный релейшн
                $childName = substr($name, $pos + 1); // Название дочернего релейшена
                $name = substr($name, 0, $pos); // Название текущего релейшена
            } else {
                $childName = null;
            }

            if (!isset($relations[$name])) { // Указываем новый релейшн
                $relation = $model->getRelation($name); // Берем запрос
                $relation->primaryModel = null;
                $relations[$name] = $relation;
            } else {
                $relation = $relations[$name];
            }

            if (isset($childName)) {
                $relation->with[$childName] = $callback;
            } elseif ($callback !== null) {
                call_user_func($callback, $relation);
            }
        }

        return $relations;
    }
    /**
     * Находит связанные записи и заполняет их в первичных моделях.
     * @param string $name - имя релейшена
     * @param array $primaryModels - первичные модели
     * @return Collection|BaseBitrixModel[] - найденные модели
     */
    public function populateRelation($name, &$primaryModels)
    {
        $this->filterByModels($primaryModels);

        $models = $this->getList();

        Helpers::assocModels($primaryModels, $models, $this->foreignKey, $this->localKey, $name, $this->multiple);

        return $models;
    }
}


<?php

namespace Arrilot\BitrixModels\Queries;

use Illuminate\Support\Collection;
use Arrilot\BitrixModels\Models\UserModel;

/**
 * @method UserQuery active()
 * @method UserQuery fromGroup($groupId)
 */
class UserQuery extends OldCoreQuery
{
    /**
     * Query sort.
     *
     * @var array
     */
    public $sort = ['last_name' => 'asc'];

    /**
     * List of standard entity fields.
     *
     * @var array
     */
    protected $standardFields = [
        'ID',
        'PERSONAL_WWW',
        'PERSONAL_ZIP',
        'IS_ONLINE',
        'ACTIVE',
        'PERSONAL_ICQ',
        'PERSONAL_COUNTRY',
        'WORK_CITY',
        'LAST_LOGIN',
        'PERSONAL_GENDER',
        'PERSONAL_NOTES',
        'WORK_STATE',
        'LOGIN',
        'PERSONAL_PHOTO',
        'WORK_COMPANY',
        'WORK_ZIP',
        'EMAIL',
        'PERSONAL_PHONE',
        'WORK_DEPARTMENT',
        'WORK_COUNTRY',
        'NAME',
        'PERSONAL_FAX',
        'WORK_POSITION',
        'WORK_PROFILE',
        'LAST_NAME',
        'PERSONAL_MOBILE',
        'WORK_WWW',
        'WORK_NOTES',
        'SECOND_NAME',
        'PERSONAL_PAGER',
        'WORK_PHONE',
        'ADMIN_NOTES',
        'TIMESTAMP_X',
        'PERSONAL',
        'STREET',
        'WORK_FAX',
        'XML_ID',
        'PERSONAL_BIRTHDAY',
        'PERSONAL_MAILBOX',
        'WORK_PAGER',
        'LAST_NAME',
        'DATE_REGISTER',
        'PERSONAL_CITY',
        'WORK_STREET',
        'SECOND_NAME',
        'PERSONAL_PROFESSION',
        'PERSONAL_STATE',
        'WORK_MAILBOX',
        'STORED_HASH',
        'CHECKWORD_TIME',
        'EXTERNAL_AUTH_ID',
        'CONFIRM_CODE',
        'LOGIN_ATTEMPTS',
        'LAST_ACTIVITY_DATE',
        'AUTO_TIME_ZONE',
        'TIME_ZONE',
        'PASSWORD',
        'CHECKWORD',
        'LID',
    ];

    /**
     * Get the collection of users according to the current query.
     *
     * @return Collection
     */
    protected function loadModels()
    {
        $queryType = 'UserQuery::getList';
        $sort = $this->sort;
        $filter = $this->normalizeFilter();
        $params = [
            'SELECT'     => $this->propsMustBeSelected() ? ['UF_*'] : ($this->normalizeUfSelect() ?: false),
            'NAV_PARAMS' => $this->navigation,
            'FIELDS'     => $this->normalizeSelect(),
        ];
        $selectGroups = $this->groupsMustBeSelected();
        $keyBy = $this->keyBy;

        $callback = function() use ($sort, $filter, $params, $selectGroups){
            $users = [];
            $rsUsers = $this->bxObject->getList($sort, $sortOrder = false, $filter, $params);
            while ($arUser = $this->performFetchUsingSelectedMethod($rsUsers)) {
                if ($selectGroups) {
                    $arUser['GROUP_ID'] = $this->bxObject->getUserGroup($arUser['ID']);
                }

                $this->addItemToResultsUsingKeyBy($users, new $this->modelName($arUser['ID'], $arUser));
            }

            return new Collection($users);
        };

        return $this->handleCacheIfNeeded(compact('queryType', 'sort', 'filter', 'params', 'selectGroups', 'keyBy'), $callback);
    }

    /**
     * Get the first user with a given login.
     *
     * @param string $login
     *
     * @return UserModel
     */
    public function getByLogin($login)
    {
        $this->filter['LOGIN_EQUAL_EXACT'] = $login;

        return $this->first();
    }

    /**
     * Get the first user with a given email.
     *
     * @param string $email
     *
     * @return UserModel
     */
    public function getByEmail($email)
    {
        $this->filter['EMAIL'] = $email;

        return $this->first();
    }

    /**
     * Get count of users according the current query.
     *
     * @return int
     */
    public function count()
    {
        if ($this->queryShouldBeStopped) {
            return 0;
        }

        $queryType = 'UserQuery::count';
        $filter = $this->normalizeFilter();
        $callback = function() use ($filter) {
            return (int) $this->bxObject->getList($order = 'ID', $by = 'ASC', $filter, [
                'NAV_PARAMS' => [
                    'nTopCount' => 0,
                ],
            ])->NavRecordCount;
        };

        return $this->handleCacheIfNeeded(compact('queryType', 'filter'), $callback);
    }

    /**
     * Determine if groups must be selected.
     *
     * @return bool
     */
    protected function groupsMustBeSelected()
    {
        return in_array('GROUPS', $this->select) || in_array('GROUP_ID', $this->select) || in_array('GROUPS_ID', $this->select);
    }

    /**
     * Normalize filter before sending it to getList.
     * This prevents some inconsistency.
     *
     * @return array
     */
    protected function normalizeFilter()
    {
        $this->substituteField($this->filter, 'GROUPS', 'GROUPS_ID');
        $this->substituteField($this->filter, 'GROUP_ID', 'GROUPS_ID');

        return $this->filter;
    }

    /**
     * Normalize select before sending it to getList.
     * This prevents some inconsistency.
     *
     * @return array
     */
    protected function normalizeSelect()
    {
        if ($this->fieldsMustBeSelected()) {
            $this->select = array_merge($this->standardFields, $this->select);
        }

        $this->select[] = 'ID';

        return $this->clearSelectArray();
    }

    /**
     * Normalize select UF before sending it to getList.
     *
     * @return array
     */
    protected function normalizeUfSelect()
    {
        return preg_grep('/^(UF_+)/', $this->select);
    }

    protected function prepareMultiFilter(&$key, &$value)
    {
        $value = join(' | ', $value);
    }
}


<?php

namespace Arrilot\BitrixModels\Queries;

use Arrilot\BitrixModels\Adapters\D7Adapter;
use Illuminate\Support\Collection;

class D7Query extends BaseQuery
{
    /**
     * Query select.
     *
     * @var array
     */
    public $select = ['*'];

    /**
     * Query group by.
     *
     * @var array
     */
    public $group = [];

    /**
     * Query runtime.
     *
     * @var array
     */
    public $runtime = [];

    /**
     * Query limit.
     *
     * @var int|null
     */
    public $limit = null;

    /**
     * Query offset.
     *
     * @var int|null
     */
    public $offset = null;

    /**
     * Cache joins?
     *
     * @var bool
     */
    public $cacheJoins = false;

    /**
     * Data doubling?
     *
     * @var bool
     */
    public $dataDoubling = true;

    /**
     * Adapter to interact with Bitrix D7 API.
     *
     * @var D7Adapter
     */
    protected $bxObject;

    /**
     * Get count of users that match $filter.
     *
     * @return int
     */
    public function count()
    {
        $className = $this->bxObject->getClassName();
        $queryType = 'D7Query::count';
        $filter = $this->filter;

        $callback = function () use ($filter) {
            return (int) $this->bxObject->getCount($filter);
        };

        return $this->handleCacheIfNeeded(compact('className', 'filter', 'queryType'), $callback);
    }

    /**
     * Get list of items.
     *
     * @return Collection
     */
    protected function loadModels()
    {
        $params = [
            'select' => $this->select,
            'filter' => $this->filter,
            'group' => $this->group,
            'order' => $this->sort,
            'limit' => $this->limit,
            'offset' => $this->offset,
            'runtime' => $this->runtime,
        ];

        if ($this->cacheTtl && $this->cacheJoins) {
            $params['cache'] = ['ttl' => $this->cacheTtl, 'cache_joins' => true];
        }

        $className = $this->bxObject->getClassName();
        $queryType = 'D7Query::getList';
        $keyBy = $this->keyBy;

        $callback = function () use ($className, $params) {
            $rows = [];
            $result = $this->bxObject->getList($params);
            while ($row = $result->fetch()) {
                $this->addItemToResultsUsingKeyBy($rows, new $this->modelName($row['ID'], $row));
            }

            return new Collection($rows);
        };

        return $this->handleCacheIfNeeded(compact('className', 'params', 'queryType', 'keyBy'), $callback);
    }

    /**
     * Setter for limit.
     *
     * @param  int|null  $value
     * @return $this
     */
    public function limit($value)
    {
        $this->limit = $value;

        return $this;
    }

    /**
     * Setter for offset.
     *
     * @param  int|null  $value
     * @return $this
     */
    public function offset($value)
    {
        $this->offset = $value;

        return $this;
    }

    /**
     * Set the "page number" value of the query.
     *
     * @param int $num
     * @return $this
     */
    public function page($num)
    {
        return $this->offset((int) $this->limit * ($num - 1));
    }

    /**
     * Setter for offset.
     *
     * @param  array|\Bitrix\Main\Entity\ExpressionField $fields
     * @return $this
     */
    public function runtime($fields)
    {
        $this->runtime = is_array($fields) ? $fields : [$fields];

        return $this;
    }

    /**
     * Setter for cacheJoins.
     *
     * @param  bool $value
     * @return $this
     */
    public function cacheJoins($value = true)
    {
        $this->cacheJoins = $value;

        return $this;
    }

    public function enableDataDoubling()
    {
        $this->dataDoubling = true;

        return $this;
    }

    public function disableDataDoubling()
    {
        $this->dataDoubling = false;

        return $this;
    }

    /**
     * For testing.
     *
     * @param $bxObject
     * @return $this
     */
    public function setAdapter($bxObject)
    {
        $this->bxObject = $bxObject;

        return $this;
    }
}


<?php

namespace Arrilot\BitrixModels\Queries;

use Arrilot\BitrixModels\Models\BaseBitrixModel;
use BadMethodCallException;
use Bitrix\Main\Data\Cache;
use Closure;
use CPHPCache;
use Illuminate\Pagination\Paginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use LogicException;

abstract class BaseQuery
{
    use BaseRelationQuery;

    /**
     * Query select.
     *
     * @var array
     */
    public $select = [];
    /**
     * Bitrix object to be queried.
     *
     * @var object|string
     */
    protected $bxObject;

    /**
     * Name of the model that calls the query.
     *
     * @var string
     */
    protected $modelName;

    /**
     * Model that calls the query.
     *
     * @var object
     */
    protected $model;

    /**
     * Query sort.
     *
     * @var array
     */
    public $sort = [];

    /**
     * Query filter.
     *
     * @var array
     */
    public $filter = [];

    /**
     * Query navigation.
     *
     * @var array|bool
     */
    public $navigation = false;

    /**
     * The key to list items in array of results.
     * Set to false to have auto incrementing integer.
     *
     * @var string|bool
     */
    public $keyBy = 'ID';

    /**
     * Number of minutes to cache a query
     *
     * @var double|int
     */
    public $cacheTtl = 0;

    /**
     * Indicates that the query should be stopped instead of touching the DB.
     * Can be set in query scopes or manually.
     *
     * @var bool
     */
    protected $queryShouldBeStopped = false;

    /**
     * Get count of users that match $filter.
     *
     * @return int
     */
    abstract public function count();

    /**
     * Подготавливает запрос и вызывает loadModels()
     *
     * @return Collection
     */
    public function getList()
    {
        if (!is_null($this->primaryModel)) {
            // Запрос - подгрузка релейшена. Надо добавить filter
            $this->filterByModels([$this->primaryModel]);
        }

        if ($this->queryShouldBeStopped) {
            return new Collection();
        }

        $models = $this->loadModels();

        if (!empty($this->with)) {
            $this->findWith($this->with, $models);
        }

        return $models;
    }

    /**
     * Get list of items.
     *
     * @return Collection
     */
    abstract protected function loadModels();

    /**
     * Constructor.
     *
     * @param object|string $bxObject
     * @param string $modelName
     */
    public function __construct($bxObject, $modelName)
    {
        $this->bxObject = $bxObject;
        $this->modelName = $modelName;
        $this->model = new $modelName();
    }

    /**
     * Get the first item that matches query params.
     *
     * @return mixed
     */
    public function first()
    {
        return $this->limit(1)->getList()->first(null, false);
    }

    /**
     * Get item by its id.
     *
     * @param int $id
     *
     * @return mixed
     */
    public function getById($id)
    {
        if (!$id || $this->queryShouldBeStopped) {
            return false;
        }

        $this->sort = [];
        $this->filter['ID'] = $id;

        return $this->getList()->first(null, false);
    }

    /**
     * Setter for sort.
     *
     * @param mixed  $by
     * @param string $order
     *
     * @return $this
     */
    public function sort($by, $order = 'ASC')
    {
        $this->sort = is_array($by) ? $by : [$by => $order];

        return $this;
    }

    /**
     * Another setter for sort.
     *
     * @param mixed  $by
     * @param string $order
     *
     * @return $this
     */
    public function order($by, $order = 'ASC')
    {
        return $this->sort($by, $order);
    }

    /**
     * Setter for filter.
     *
     * @param array $filter
     *
     * @return $this
     */
    public function filter($filter)
    {
        $this->filter = array_merge($this->filter, $filter);

        return $this;
    }

    /**
     * Reset filter.
     *
     * @return $this
     */
    public function resetFilter()
    {
        $this->filter = [];

        return $this;
    }

    /**
     * Add another filter to filters array.
     *
     * @param array $filters
     *
     * @return $this
     */
    public function addFilter($filters)
    {
        foreach ($filters as $field => $value) {
            $this->filter[$field] = $value;
        }

        return $this;
    }

    /**
     * Setter for navigation.
     *
     * @param $value
     *
     * @return $this
     */
    public function navigation($value)
    {
        $this->navigation = $value;

        return $this;
    }

    /**
     * Setter for select.
     *
     * @param $value
     *
     * @return $this
     */
    public function select($value)
    {
        $this->select = is_array($value) ? $value : func_get_args();

        return $this;
    }

    /**
     * Setter for cache ttl.
     *
     * @param float|int $minutes
     *
     * @return $this
     */
    public function cache($minutes)
    {
        $this->cacheTtl = $minutes;

        return $this;
    }

    /**
     * Setter for keyBy.
     *
     * @param string $value
     *
     * @return $this
     */
    public function keyBy($value)
    {
        $this->keyBy = $value;

        return $this;
    }

    /**
     * Set the "limit" value of the query.
     *
     * @param int $value
     *
     * @return $this
     */
    public function limit($value)
    {
        $this->navigation['nPageSize'] = $value;

        return $this;
    }

    /**
     * Set the "page number" value of the query.
     *
     * @param int $num
     *
     * @return $this
     */
    public function page($num)
    {
        $this->navigation['iNumPage'] = $num;

        return $this;
    }

    /**
     * Alias for "limit".
     *
     * @param int $value
     *
     * @return $this
     */
    public function take($value)
    {
        return $this->limit($value);
    }

    /**
     * Set the limit and offset for a given page.
     *
     * @param  int  $page
     * @param  int  $perPage
     * @return $this
     */
    public function forPage($page, $perPage = 15)
    {
        return $this->take($perPage)->page($page);
    }

    /**
     * Paginate the given query into a paginator.
     *
     * @param  int  $perPage
     * @param  string  $pageName
     *
     * @return \Illuminate\Pagination\LengthAwarePaginator
     */
    public function paginate($perPage = 15, $pageName = 'page')
    {
        $page = Paginator::resolveCurrentPage($pageName);
        $total = $this->count();
        $results = $this->forPage($page, $perPage)->getList();

        return new LengthAwarePaginator($results, $total, $perPage, $page, [
            'path' => Paginator::resolveCurrentPath(),
            'pageName' => $pageName,
        ]);
    }

    /**
     * Get a paginator only supporting simple next and previous links.
     *
     * This is more efficient on larger data-sets, etc.
     *
     * @param  int  $perPage
     * @param  string  $pageName
     *
     * @return \Illuminate\Pagination\Paginator
     */
    public function simplePaginate($perPage = 15, $pageName = 'page')
    {
        $page = Paginator::resolveCurrentPage($pageName);
        $results = $this->forPage($page, $perPage + 1)->getList();

        return new Paginator($results, $perPage, $page, [
            'path' => Paginator::resolveCurrentPath(),
            'pageName' => $pageName,
        ]);
    }

    /**
     * Stop the query from touching DB.
     *
     * @return $this
     */
    public function stopQuery()
    {
        $this->queryShouldBeStopped = true;

        return $this;
    }

    /**
     * Adds $item to $results using keyBy value.
     *
     * @param $results
     * @param BaseBitrixModel $object
     *
     * @return void
     */
    protected function addItemToResultsUsingKeyBy(&$results, BaseBitrixModel $object)
    {
        $item = $object->fields;
        if (!array_key_exists($this->keyBy, $item)) {
            throw new LogicException("Field {$this->keyBy} is not found in object");
        }

        $keyByValue = $item[$this->keyBy];

        if (!isset($results[$keyByValue])) {
            $results[$keyByValue] = $object;
        } else {
            $oldFields = $results[$keyByValue]->fields;
            foreach ($oldFields as $field => $oldValue) {
                // пропускаем служебные поля.
                if (in_array($field, ['_were_multiplied', 'PROPERTIES'])) {
                    continue;
                }

                $alreadyMultiplied = !empty($oldFields['_were_multiplied'][$field]);

                // мультиплицируем только несовпадающие значения полей
                $newValue = $item[$field];
                if ($oldValue !== $newValue) {
                    // если еще не мультиплицировали поле, то его надо превратить в массив.
                    if (!$alreadyMultiplied) {
                        $oldFields[$field] = [
                            $oldFields[$field]
                        ];
                        $oldFields['_were_multiplied'][$field] = true;
                    }

                    // добавляем новое значению поле если такого еще нет.
                    if (empty($oldFields[$field]) || (is_array($oldFields[$field]) && !in_array($newValue, $oldFields[$field]))) {
                        $oldFields[$field][] = $newValue;
                    }
                }
            }

            $results[$keyByValue]->fields = $oldFields;
        }
    }

    /**
     * Determine if all fields must be selected.
     *
     * @return bool
     */
    protected function fieldsMustBeSelected()
    {
        return in_array('FIELDS', $this->select);
    }

    /**
     * Determine if all fields must be selected.
     *
     * @return bool
     */
    protected function propsMustBeSelected()
    {
        return in_array('PROPS', $this->select)
            || in_array('PROPERTIES', $this->select)
            || in_array('PROPERTY_VALUES', $this->select);
    }

    /**
     * Set $array[$new] as $array[$old] and delete $array[$old].
     *
     * @param array $array
     * @param $old
     * @param $new
     *
     * return null
     */
    protected function substituteField(&$array, $old, $new)
    {
        if (isset($array[$old]) && !isset($array[$new])) {
            $array[$new] = $array[$old];
        }

        unset($array[$old]);
    }

    /**
     * Clear select array from duplication and additional fields.
     *
     * @return array
     */
    protected function clearSelectArray()
    {
        $strip = ['FIELDS', 'PROPS', 'PROPERTIES', 'PROPERTY_VALUES', 'GROUPS', 'GROUP_ID', 'GROUPS_ID'];

        return array_values(array_diff(array_unique($this->select), $strip));
    }

    /**
     * Store closure's result in the cache for a given number of minutes.
     *
     * @param string $key
     * @param double $minutes
     * @param Closure $callback
     * @return mixed
     */
    protected function rememberInCache($key, $minutes, Closure $callback)
    {
        $minutes = (double) $minutes;
        if ($minutes <= 0) {
            return $callback();
        }

        $cache = Cache::createInstance();
        if ($cache->initCache($minutes * 60, $key, '/bitrix-models')) {
            $vars = $cache->getVars();
            return !empty($vars['isCollection']) ? new Collection($vars['cache']) : $vars['cache'];
        }

        $cache->startDataCache();
        $result = $callback();

        // Bitrix cache is bad for storing collections. Let's convert it to array.
        $isCollection = $result instanceof Collection;
        if ($isCollection) {
            $result = $result->all();
        }

        $cache->endDataCache(['cache' => $result, 'isCollection' => $isCollection]);

        return $isCollection ? new Collection($result) : $result;
    }

    protected function handleCacheIfNeeded($cacheKeyParams, Closure $callback)
    {
        return $this->cacheTtl
            ? $this->rememberInCache(md5(json_encode($cacheKeyParams)), $this->cacheTtl, $callback)
            : $callback();
    }

    /**
     * Handle dynamic method calls into the method.
     *
     * @param string $method
     * @param array  $parameters
     *
     * @throws BadMethodCallException
     *
     * @return $this
     */
    public function __call($method, $parameters)
    {
        if (method_exists($this->model, 'scope'.$method)) {
            array_unshift($parameters, $this);

            $query = call_user_func_array([$this->model, 'scope'.$method], $parameters);

            if ($query === false) {
                $this->stopQuery();
            }

            return $query instanceof static ? $query : $this;
        }

        $className = get_class($this);

        throw new BadMethodCallException("Call to undefined method {$className}::{$method}()");
    }

    protected function prepareMultiFilter(&$key, &$value)
    {

    }
}


<?php

namespace Arrilot\BitrixModels;

use Arrilot\BitrixModels\Models\BaseBitrixModel;
use Illuminate\Support\Collection;

class Helpers
{
    /**
     * Does the $haystack starts with $needle
     *
     * @param $haystack
     * @param $needle
     * @return bool
     */
    public static function startsWith($haystack, $needle)
    {
        return strncmp($haystack, $needle, strlen($needle)) === 0;
    }

    /**
     * @param Collection|BaseBitrixModel[] $primaryModels первичные модели
     * @param Collection|BaseBitrixModel[] $relationModels подгруженные связанные модели
     * @param string $primaryKey ключ связи в первичной модели
     * @param string $relationKey ключ связи в связанной модели
     * @param string $relationName название связи в первичной модели
     * @param bool $multiple множественная ли это свзязь
     */
    public static function assocModels($primaryModels, $relationModels, $primaryKey, $relationKey, $relationName, $multiple)
    {
        $buckets = static::buildBuckets($relationModels, $relationKey, $multiple);

        foreach ($primaryModels as $i => $primaryModel) {
            if ($multiple && is_array($keys = $primaryModel[$primaryKey])) {
                $value = [];
                foreach ($keys as $key) {
                    $key = static::normalizeModelKey($key);
                    if (isset($buckets[$key])) {
                        $value = array_merge($value, $buckets[$key]);
                    }
                }
            } else {
                $key = static::normalizeModelKey($primaryModel[$primaryKey]);
                $value = isset($buckets[$key]) ? $buckets[$key] : ($multiple ? [] : null);
            }

            $primaryModel->populateRelation($relationName, is_array($value) ? (new Collection($value))->keyBy(function ($item) {return $item->id;}) : $value);
        }
    }

    /**
     * Сгруппировать найденные модели
     * @param array $models
     * @param string $linkKey
     * @param bool $multiple
     * @return array
     */
    protected static function buildBuckets($models, $linkKey, $multiple)
    {
        $buckets = [];

        foreach ($models as $model) {
            $key = $model[$linkKey];
            if (is_scalar($key)) {
                $buckets[$key][] = $model;
            } elseif (is_array($key)){
                foreach ($key as $k) {
                    $k = static::normalizeModelKey($k);
                    $buckets[$k][] = $model;
                }
            } else {
                $key = static::normalizeModelKey($key);
                $buckets[$key][] = $model;
            }
        }

        if (!$multiple) {
            foreach ($buckets as $i => $bucket) {
                $buckets[$i] = reset($bucket);
            }
        }

        return $buckets;
    }

    /**
     * @param mixed $value raw key value.
     * @return string normalized key value.
     */
    protected static function normalizeModelKey($value)
    {
        if (is_object($value) && method_exists($value, '__toString')) {
            // ensure matching to special objects, which are convertable to string, for cross-DBMS relations, for example: `|MongoId`
            $value = $value->__toString();
        }

        return $value;
    }
}


<?php

namespace Arrilot\BitrixModels\Queries;

use Arrilot\BitrixModels\Helpers;
use Illuminate\Support\Collection;
use Arrilot\BitrixModels\Models\SectionModel;

/**
 * @method SectionQuery active()
 */
class SectionQuery extends OldCoreQuery
{
    /**
     * Query sort.
     *
     * @var array
     */
    public $sort = ['SORT' => 'ASC'];

    /**
     * Query bIncCnt.
     * This is sent to getList directly.
     *
     * @var array|false
     */
    public $countElements = false;

    /**
     * Iblock id.
     *
     * @var int
     */
    protected $iblockId;

    /**
     * List of standard entity fields.
     *
     * @var array
     */
    protected $standardFields = [
        'ID',
        'CODE',
        'EXTERNAL_ID',
        'IBLOCK_ID',
        'IBLOCK_SECTION_ID',
        'TIMESTAMP_X',
        'SORT',
        'NAME',
        'ACTIVE',
        'GLOBAL_ACTIVE',
        'PICTURE',
        'DESCRIPTION',
        'DESCRIPTION_TYPE',
        'LEFT_MARGIN',
        'RIGHT_MARGIN',
        'DEPTH_LEVEL',
        'SEARCHABLE_CONTENT',
        'SECTION_PAGE_URL',
        'MODIFIED_BY',
        'DATE_CREATE',
        'CREATED_BY',
        'DETAIL_PICTURE',
    ];

    /**
     * Constructor.
     *
     * @param object $bxObject
     * @param string $modelName
     */
    public function __construct($bxObject, $modelName)
    {
        parent::__construct($bxObject, $modelName);

        $this->iblockId = $modelName::iblockId();
    }

    /**
     * CIBlockSection::getList substitution.
     *
     * @return Collection
     */
    protected function loadModels()
    {
        $queryType = 'SectionQuery::getList';
        $sort = $this->sort;
        $filter = $this->normalizeFilter();
        $countElements = $this->countElements;
        $select = $this->normalizeSelect();
        $navigation = $this->navigation;
        $keyBy = $this->keyBy;

        $callback = function() use ($sort, $filter, $countElements, $select, $navigation) {
            $sections = [];
            $rsSections = $this->bxObject->getList($sort, $filter, $countElements, $select, $navigation);
            while ($arSection = $this->performFetchUsingSelectedMethod($rsSections)) {

                // Если передать nPageSize, то Битрикс почему-то перестает десериализовать множественные свойсвта...
                // Проверим это еще раз, и если есть проблемы то пофиксим.
                foreach ($arSection as $field => $value) {
                    if (
                    is_string($value)
                    && Helpers::startsWith($value, 'a:')
                    && (Helpers::startsWith($field, 'UF_') || Helpers::startsWith($field, '~UF_'))
                    ) {
                        $unserializedValue = @unserialize($value);
                        $arSection[$field] = $unserializedValue === false ? $value : $unserializedValue;
                    }
                }

                $this->addItemToResultsUsingKeyBy($sections, new $this->modelName($arSection['ID'], $arSection));
            }

            return new Collection($sections);
        };

        $cacheParams = compact('queryType', 'sort', 'filter', 'countElements', 'select', 'navigation', 'keyBy');

        return $this->handleCacheIfNeeded($cacheParams, $callback);
    }

    /**
     * Get the first section with a given code.
     *
     * @param string $code
     *
     * @return SectionModel
     */
    public function getByCode($code)
    {
        $this->filter['CODE'] = $code;

        return $this->first();
    }

    /**
     * Get the first section with a given external id.
     *
     * @param string $id
     *
     * @return SectionModel
     */
    public function getByExternalId($id)
    {
        $this->filter['EXTERNAL_ID'] = $id;

        return $this->first();
    }

    /**
     * Get count of sections that match filter.
     *
     * @return int
     */
    public function count()
    {
        if ($this->queryShouldBeStopped) {
            return 0;
        }

        $queryType = 'SectionQuery::count';
        $filter = $this->normalizeFilter();
        $callback = function() use ($filter) {
            return (int) $this->bxObject->getCount($filter);
        };

        return $this->handleCacheIfNeeded(compact('queryType', 'filter'), $callback);
    }

    /**
     * Setter for countElements.
     *
     * @param $value
     *
     * @return $this
     */
    public function countElements($value)
    {
        $this->countElements = $value;

        return $this;
    }

    /**
     * Normalize filter before sending it to getList.
     * This prevents some inconsistency.
     *
     * @return array
     */
    protected function normalizeFilter()
    {
        $this->filter['IBLOCK_ID'] = $this->iblockId;

        return $this->filter;
    }

    /**
     * Normalize select before sending it to getList.
     * This prevents some inconsistency.
     *
     * @return array
     */
    protected function normalizeSelect()
    {
        if ($this->fieldsMustBeSelected()) {
            $this->select = array_merge($this->standardFields, $this->select);
        }

        if ($this->propsMustBeSelected()) {
            $this->select[] = 'IBLOCK_ID';
            $this->select[] = 'UF_*';
        }

        $this->select[] = 'ID';

        return $this->clearSelectArray();
    }
}


<?php

namespace Arrilot\BitrixModels\Models;

use Arrilot\BitrixModels\Queries\UserQuery;
use Illuminate\Support\Collection;

/**
 * UserQuery methods
 * @method static static getByLogin(string $login)
 * @method static static getByEmail(string $email)
 *
 * Base Query methods
 * @method static Collection|static[] getList()
 * @method static static first()
 * @method static static getById(int $id)
 * @method static UserQuery sort(string|array $by, string $order='ASC')
 * @method static UserQuery order(string|array $by, string $order='ASC') // same as sort()
 * @method static UserQuery filter(array $filter)
 * @method static UserQuery addFilter(array $filters)
 * @method static UserQuery resetFilter()
 * @method static UserQuery navigation(array $filter)
 * @method static UserQuery select($value)
 * @method static UserQuery keyBy(string $value)
 * @method static UserQuery limit(int $value)
 * @method static UserQuery offset(int $value)
 * @method static UserQuery page(int $num)
 * @method static UserQuery take(int $value) // same as limit()
 * @method static UserQuery forPage(int $page, int $perPage=15)
 * @method static \Illuminate\Pagination\LengthAwarePaginator paginate(int $perPage = 15, string $pageName = 'page')
 * @method static \Illuminate\Pagination\Paginator simplePaginate(int $perPage = 15, string $pageName = 'page')
 * @method static UserQuery stopQuery()
 * @method static UserQuery cache(float|int $minutes)
 *
 * Scopes
 * @method static UserQuery active()
 * @method UserQuery fromGroup(int $groupId)
 */
class UserModel extends BitrixModel
{
    /**
     * Bitrix entity object.
     *
     * @var object
     */
    public static $bxObject;

    /**
     * Corresponding object class name.
     *
     * @var string
     */
    protected static $objectClass = 'CUser';

    /**
     * Current user cache.
     *
     * @var static
     */
    protected static $currentUser = null;

    /**
     * Have groups been already fetched from DB?
     *
     * @var bool
     */
    protected $groupsAreFetched = false;

    /**
     * Instantiate a query object for the model.
     *
     * @return UserQuery
     */
    public static function query()
    {
        return new UserQuery(static::instantiateObject(), get_called_class());
    }

    /**
     * Get a new instance for the current user
     *
     * @return static
     */
    public static function current()
    {
        return is_null(static::$currentUser)
            ? static::freshCurrent()
            : static::$currentUser;
    }

    /**
     * Get a fresh instance for the current user and save it to local cache.
     *
     * @return static
     */
    public static function freshCurrent()
    {
        global $USER;

        return static::$currentUser = (new static($USER->getId()))->load();
    }

    /**
     * Fill extra fields when $this->field is called.
     *
     * @return null
     */
    protected function afterFill()
    {
        if (isset($this->fields['GROUP_ID']) && is_array(['GROUP_ID'])) {
            $this->groupsAreFetched = true;
        }
    }

    /**
     * Fill model groups if they are already known.
     * Saves DB queries.
     *
     * @param array $groups
     *
     * @return null
     */
    public function fillGroups($groups)
    {
        $this->fields['GROUP_ID'] = $groups;

        $this->groupsAreFetched = true;
    }

    /**
     * Load model fields from database if they are not loaded yet.
     *
     * @return $this
     */
    public function load()
    {
        $this->getFields();
        $this->getGroups();

        return $this;
    }

    /**
     * Get user groups from cache or database.
     *
     * @return array
     */
    public function getGroups()
    {
        if ($this->groupsAreFetched) {
            return $this->fields['GROUP_ID'];
        }

        return $this->refreshGroups();
    }

    /**
     * Refresh model from database and place data to $this->fields.
     *
     * @return array
     */
    public function refresh()
    {
        $this->refreshFields();

        $this->refreshGroups();

        return $this->fields;
    }

    /**
     * Refresh user fields and save them to a class field.
     *
     * @return array
     */
    public function refreshFields()
    {
        if ($this->id === null) {
            $this->original = [];
            return $this->fields = [];
        }

        $groupBackup = isset($this->fields['GROUP_ID']) ? $this->fields['GROUP_ID'] : null;

        $this->fields = static::query()->getById($this->id)->fields;

        if ($groupBackup) {
            $this->fields['GROUP_ID'] = $groupBackup;
        }

        $this->fieldsAreFetched = true;

        $this->original = $this->fields;

        return $this->fields;
    }

    /**
     * Refresh user groups and save them to a class field.
     *
     * @return array
     */
    public function refreshGroups()
    {
        if ($this->id === null) {
            return [];
        }

        global $USER;

        $this->fields['GROUP_ID'] = $this->isCurrent()
            ? $USER->getUserGroupArray()
            : static::$bxObject->getUserGroup($this->id);

        $this->groupsAreFetched = true;

        return $this->fields['GROUP_ID'];
    }

    /**
     * Check if user is an admin.
     */
    public function isAdmin()
    {
        return $this->hasGroupWithId(1);
    }

    /**
     * Check if this user is the operating user.
     */
    public function isCurrent()
    {
        global $USER;

        return $USER->getId() && $this->id == $USER->getId();
    }

    /**
     * Check if user has role with a given ID.
     *
     * @param $role_id
     *
     * @return bool
     */
    public function hasGroupWithId($role_id)
    {
        return in_array($role_id, $this->getGroups());
    }

    /**
     * Check if user is authorized.
     *
     * @return bool
     */
    public function isAuthorized()
    {
        global $USER;

        return ($USER->getId() == $this->id) && $USER->isAuthorized();
    }

    /**
     * Check if user is guest.
     *
     * @return bool
     */
    public function isGuest()
    {
        return ! $this->isAuthorized();
    }

    /**
     * Logout user.
     *
     * @return void
     */
    public function logout()
    {
        global $USER;

        $USER->logout();
    }

    /**
     * Scope to get only users from a given group / groups.
     *
     * @param UserQuery $query
     * @param int|array $id
     *
     * @return UserQuery
     */
    public function scopeFromGroup($query, $id)
    {
        $query->filter['GROUPS_ID'] = $id;

        return $query;
    }

    /**
     * Substitute old group with the new one.
     *
     * @param int $old
     * @param int $new
     *
     * @return void
     */
    public function substituteGroup($old, $new)
    {
        $groups = $this->getGroups();

        if(($key = array_search($old, $groups)) !== false) {
            unset($groups[$key]);
        }

        if (!in_array($new, $groups)) {
            $groups[] = $new;
        }

        $this->fields['GROUP_ID'] = $groups;
    }
}


<?php

namespace Arrilot\BitrixModels\Models;

use Arrilot\BitrixModels\Adapters\D7Adapter;
use Arrilot\BitrixModels\Exceptions\ExceptionFromBitrix;
use Arrilot\BitrixModels\Queries\D7Query;
use Illuminate\Support\Collection;
use Bitrix\Main\Entity\UpdateResult;
use LogicException;

/**
 * static int count()
 *
 * D7Query methods
 * @method static D7Query runtime(array|\Bitrix\Main\Entity\ExpressionField $fields)
 * @method static D7Query enableDataDoubling()
 * @method static D7Query disableDataDoubling()
 * @method static D7Query cacheJoins(bool $value)
 *
 * BaseQuery methods
 * @method static Collection|static[] getList()
 * @method static static first()
 * @method static static getById(int $id)
 * @method static D7Query sort(string|array $by, string $order='ASC')
 * @method static D7Query order(string|array $by, string $order='ASC') // same as sort()
 * @method static D7Query filter(array $filter)
 * @method static D7Query addFilter(array $filters)
 * @method static D7Query resetFilter()
 * @method static D7Query navigation(array $filter)
 * @method static D7Query select($value)
 * @method static D7Query keyBy(string $value)
 * @method static D7Query limit(int $value)
 * @method static D7Query offset(int $value)
 * @method static D7Query page(int $num)
 * @method static D7Query take(int $value) // same as limit()
 * @method static D7Query forPage(int $page, int $perPage=15)
 * @method static \Illuminate\Pagination\LengthAwarePaginator paginate(int $perPage = 15, string $pageName = 'page')
 * @method static \Illuminate\Pagination\Paginator simplePaginate(int $perPage = 15, string $pageName = 'page')
 * @method static D7Query stopQuery()
 * @method static D7Query cache(float|int $minutes)
 */
class D7Model extends BaseBitrixModel
{
    const TABLE_CLASS = null;

    /**
     * @var null|string
     */
    protected static $cachedTableClasses = [];

    /**
     * Array of adapters for each model to interact with Bitrix D7 API.
     *
     * @var D7Adapter[]
     */
    protected static $adapters = [];

    /**
     * Constructor.
     *
     * @param $id
     * @param $fields
     */
    public function __construct($id = null, $fields = null)
    {
        $this->id = $id;
        $this->fill($fields);
        static::instantiateAdapter();
    }

    /**
     * Setter for adapter (for testing)
     * @param $adapter
     */
    public static function setAdapter($adapter)
    {
        static::$adapters[get_called_class()] = $adapter;
    }

    /**
     * Instantiate adapter if it's not instantiated.
     *
     * @return D7Adapter
     */
    public static function instantiateAdapter()
    {
        $class = get_called_class();
        if (isset(static::$adapters[$class])) {
            return static::$adapters[$class];
        }

        return static::$adapters[$class] = new D7Adapter(static::cachedTableClass());
    }

    /**
     * Instantiate a query object for the model.
     *
     * @return D7Query
     */
    public static function query()
    {
        return new D7Query(static::instantiateAdapter(), get_called_class());
    }

    /**
     * @return string
     * @throws LogicException
     */
    public static function tableClass()
    {
        $tableClass = static::TABLE_CLASS;
        if (!$tableClass) {
            throw new LogicException('You must set TABLE_CLASS constant inside a model or override tableClass() method');
        }

        return $tableClass;
    }

    /**
     * Cached version of table class.
     *
     * @return string
     */
    public static function cachedTableClass()
    {
        $class = get_called_class();
        if (!isset(static::$cachedTableClasses[$class])) {
            static::$cachedTableClasses[$class] = static::tableClass();
        }

        return static::$cachedTableClasses[$class];
    }

    /**
     * Internal part of create to avoid problems with static and inheritance
     *
     * @param $fields
     *
     * @throws ExceptionFromBitrix
     *
     * @return static|bool
     */
    protected static function internalCreate($fields)
    {
        $model = new static(null, $fields);

        if ($model->onBeforeSave() === false || $model->onBeforeCreate() === false) {
            return false;
        }

        $resultObject = static::instantiateAdapter()->add($model->fields);
        $result = $resultObject->isSuccess();
        if ($result) {
            $model->setId($resultObject->getId());
        }

        $model->setEventErrorsOnFail($resultObject);
        $model->onAfterCreate($result);
        $model->onAfterSave($result);
        $model->throwExceptionOnFail($resultObject);

        return $model;
    }

    /**
     * Delete model
     *
     * @return bool
     * @throws ExceptionFromBitrix
     */
    public function delete()
    {
        if ($this->onBeforeDelete() === false) {
            return false;
        }

        $resultObject = static::instantiateAdapter()->delete($this->id);
        $result = $resultObject->isSuccess();

        $this->setEventErrorsOnFail($resultObject);
        $this->onAfterDelete($result);
        $this->resetEventErrors();
        $this->throwExceptionOnFail($resultObject);

        return $result;
    }

    /**
     * Save model to database.
     *
     * @param array $selectedFields save only these fields instead of all.
     * @return bool
     * @throws ExceptionFromBitrix
     */
    public function save($selectedFields = [])
    {
        $fieldsSelectedForSave = is_array($selectedFields) ? $selectedFields : func_get_args();
        $this->fieldsSelectedForSave = $fieldsSelectedForSave;
        if ($this->onBeforeSave() === false || $this->onBeforeUpdate() === false) {
            $this->fieldsSelectedForSave = [];
            return false;
        } else {
            $this->fieldsSelectedForSave = [];
        }

        $fields = $this->normalizeFieldsForSave($fieldsSelectedForSave);
        $resultObject = $fields === null
            ? new UpdateResult()
            : static::instantiateAdapter()->update($this->id, $fields);
        $result = $resultObject->isSuccess();

        $this->setEventErrorsOnFail($resultObject);
        $this->onAfterUpdate($result);
        $this->onAfterSave($result);
        $this->throwExceptionOnFail($resultObject);

        return $result;
    }

    /**
     * Determine whether the field should be stopped from passing to "update".
     *
     * @param string $field
     * @param mixed  $value
     * @param array  $selectedFields
     *
     * @return bool
     */
    protected function fieldShouldNotBeSaved($field, $value, $selectedFields)
    {
        return (!empty($selectedFields) && !in_array($field, $selectedFields)) || $field === 'ID';
    }

    /**
     * Throw bitrix exception on fail
     *
     * @param \Bitrix\Main\Entity\Result $resultObject
     * @throws ExceptionFromBitrix
     */
    protected function throwExceptionOnFail($resultObject)
    {
        if (!$resultObject->isSuccess()) {
            throw new ExceptionFromBitrix(implode('; ', $resultObject->getErrorMessages()));
        }
    }

    /**
     * Set eventErrors field on error.
     *
     * @param \Bitrix\Main\Entity\Result $resultObject
     */
    protected function setEventErrorsOnFail($resultObject)
    {
        if (!$resultObject->isSuccess()) {
            $this->eventErrors = (array) $resultObject->getErrorMessages();
        }
    }
}


<?php

namespace Arrilot\BitrixModels\Queries;

use Arrilot\BitrixCacher\Cache;
use CIBlock;
use Illuminate\Support\Collection;
use Arrilot\BitrixModels\Models\ElementModel;
use Exception;

/**
 * @method ElementQuery active()
 * @method ElementQuery sortByDate(string $sort = 'desc')
 * @method ElementQuery fromSectionWithId(int $id)
 * @method ElementQuery fromSectionWithCode(string $code)
 */
class ElementQuery extends OldCoreQuery
{
    /**
     * CIblock object or test double.
     *
     * @var object.
     */
    public static $cIblockObject;

    /**
     * Query sort.
     *
     * @var array
     */
    public $sort = ['SORT' => 'ASC'];

    /**
     * Query group by.
     *
     * @var array
     */
    public $groupBy = false;

    /**
     * Iblock id.
     *
     * @var int
     */
    protected $iblockId;

    /**
     * Iblock version.
     *
     * @var int
     */
    protected $iblockVersion;

    /**
     * List of standard entity fields.
     *
     * @var array
     */
    protected $standardFields = [
        'ID',
        'TIMESTAMP_X',
        'TIMESTAMP_X_UNIX',
        'MODIFIED_BY',
        'DATE_CREATE',
        'DATE_CREATE_UNIX',
        'CREATED_BY',
        'IBLOCK_ID',
        'IBLOCK_SECTION_ID',
        'ACTIVE',
        'ACTIVE_FROM',
        'ACTIVE_TO',
        'SORT',
        'NAME',
        'PREVIEW_PICTURE',
        'PREVIEW_TEXT',
        'PREVIEW_TEXT_TYPE',
        'DETAIL_PICTURE',
        'DETAIL_TEXT',
        'DETAIL_TEXT_TYPE',
        'SEARCHABLE_CONTENT',
        'IN_SECTIONS',
        'SHOW_COUNTER',
        'SHOW_COUNTER_START',
        'CODE',
        'TAGS',
        'XML_ID',
        'EXTERNAL_ID',
        'TMP_ID',
        'CREATED_USER_NAME',
        'DETAIL_PAGE_URL',
        'LIST_PAGE_URL',
        'CREATED_DATE',
    ];

    /**
     * Constructor.
     *
     * @param object $bxObject
     * @param string $modelName
     */
    public function __construct($bxObject, $modelName)
    {
        static::instantiateCIblockObject();
        parent::__construct($bxObject, $modelName);

        $this->iblockId = $modelName::iblockId();
        $this->iblockVersion = $modelName::IBLOCK_VERSION ?: 2;
    }

    /**
     * Instantiate bitrix entity object.
     *
     * @throws Exception
     *
     * @return object
     */
    public static function instantiateCIblockObject()
    {
        if (static::$cIblockObject) {
            return static::$cIblockObject;
        }

        if (class_exists('CIBlock')) {
            return static::$cIblockObject = new CIBlock();
        }

        throw new Exception('CIblock object initialization failed');
    }

    /**
     * Setter for groupBy.
     *
     * @param $value
     *
     * @return $this
     */
    public function groupBy($value)
    {
        $this->groupBy = $value;

        return $this;
    }

    /**
     * Get list of items.
     *
     * @return Collection
     */
    protected function loadModels()
    {
        $sort = $this->sort;
        $filter = $this->normalizeFilter();
        $groupBy = $this->groupBy;
        $navigation = $this->navigation;
        $select = $this->normalizeSelect();
        $queryType = 'ElementQuery::getList';
        $fetchUsing = $this->fetchUsing;
        $keyBy = $this->keyBy;
        list($select, $chunkQuery) = $this->multiplySelectForMaxJoinsRestrictionIfNeeded($select);

        $callback = function() use ($sort, $filter, $groupBy, $navigation, $select, $chunkQuery) {
            if ($chunkQuery) {
                $itemsChunks = [];
                foreach ($select as $chunkIndex => $selectForChunk) {
                    $rsItems = $this->bxObject->GetList($sort, $filter, $groupBy, $navigation, $selectForChunk);
                    while ($arItem = $this->performFetchUsingSelectedMethod($rsItems)) {
                        $this->addItemToResultsUsingKeyBy($itemsChunks[$chunkIndex], new $this->modelName($arItem['ID'], $arItem));
                    }
                }

                $items = $this->mergeChunks($itemsChunks);
            } else {
                $items = [];
                $rsItems = $this->bxObject->GetList($sort, $filter, $groupBy, $navigation, $select);
                while ($arItem = $this->performFetchUsingSelectedMethod($rsItems)) {
                    $this->addItemToResultsUsingKeyBy($items, new $this->modelName($arItem['ID'], $arItem));
                }
            }
            return new Collection($items);
        };

        $cacheKeyParams = compact('sort', 'filter', 'groupBy', 'navigation', 'select', 'queryType', 'keyBy', 'fetchUsing');

        return $this->handleCacheIfNeeded($cacheKeyParams, $callback);
    }

    /**
     * Get the first element with a given code.
     *
     * @param string $code
     *
     * @return ElementModel
     */
    public function getByCode($code)
    {
        $this->filter['CODE'] = $code;

        return $this->first();
    }

    /**
     * Get the first element with a given external id.
     *
     * @param string $id
     *
     * @return ElementModel
     */
    public function getByExternalId($id)
    {
        $this->filter['EXTERNAL_ID'] = $id;

        return $this->first();
    }

    /**
     * Get count of elements that match $filter.
     *
     * @return int
     */
    public function count()
    {
        if ($this->queryShouldBeStopped) {
            return 0;
        }

        $filter = $this->normalizeFilter();
        $queryType = "ElementQuery::count";

        $callback = function () use ($filter) {
            return (int) $this->bxObject->GetList(false, $filter, []);
        };

        return $this->handleCacheIfNeeded(compact('filter', 'queryType'), $callback);
    }

//    /**
//     * Normalize properties's format converting it to 'PROPERTY_"CODE"_VALUE'.
//     *
//     * @param array $fields
//     *
//     * @return null
//     */
//    protected function normalizePropertyResultFormat(&$fields)
//    {
//        if (empty($fields['PROPERTIES'])) {
//            return;
//        }
//
//        foreach ($fields['PROPERTIES'] as $code => $prop) {
//            $fields['PROPERTY_'.$code.'_VALUE'] = $prop['VALUE'];
//            $fields['~PROPERTY_'.$code.'_VALUE'] = $prop['~VALUE'];
//            $fields['PROPERTY_'.$code.'_DESCRIPTION'] = $prop['DESCRIPTION'];
//            $fields['~PROPERTY_'.$code.'_DESCRIPTION'] = $prop['~DESCRIPTION'];
//            $fields['PROPERTY_'.$code.'_VALUE_ID'] = $prop['PROPERTY_VALUE_ID'];
//            if (isset($prop['VALUE_ENUM_ID'])) {
//                $fields['PROPERTY_'.$code.'_ENUM_ID'] = $prop['VALUE_ENUM_ID'];
//            }
//        }
//    }

    /**
     * Normalize filter before sending it to getList.
     * This prevents some inconsistency.
     *
     * @return array
     */
    protected function normalizeFilter()
    {
        $this->filter['IBLOCK_ID'] = $this->iblockId;

        return $this->filter;
    }

    /**
     * Normalize select before sending it to getList.
     * This prevents some inconsistency.
     *
     * @return array
     */
    protected function normalizeSelect()
    {
        if ($this->fieldsMustBeSelected()) {
            $this->select = array_merge($this->standardFields, $this->select);
        }

        $this->select[] = 'ID';
        $this->select[] = 'IBLOCK_ID';

        return $this->clearSelectArray();
    }

    /**
     * Fetch all iblock property codes from database
     *
     * return array
     */
    protected function fetchAllPropsForSelect()
    {
        $props = [];
        $rsProps = static::$cIblockObject->GetProperties($this->iblockId);
        while ($prop = $rsProps->Fetch()) {
            $props[] = 'PROPERTY_'.$prop['CODE'];
        }

        return $props;
    }

    protected function multiplySelectForMaxJoinsRestrictionIfNeeded($select)
    {
        if (!$this->propsMustBeSelected()) {
            return [$select, false];
        }

        $chunkSize = 20;
        $props = $this->fetchAllPropsForSelect();
        if ($this->iblockVersion !== 1 || (count($props) <= $chunkSize)) {
            return [array_merge($select, $props), false];
        }

        // начинаем формировать селекты из свойств
        $multipleSelect = array_chunk($props, $chunkSize);

        // добавляем в каждый селект поля "несвойства"
        foreach ($multipleSelect as $i => $partOfProps) {
            $multipleSelect[$i] = array_merge($select, $partOfProps);
        }

        return [$multipleSelect, true];
    }

    protected function mergeChunks($chunks)
    {
        $items = [];
        foreach ($chunks as $chunk) {
            foreach ($chunk as $k => $item) {
                if (isset($items[$k])) {
                    $item->fields['_were_multiplied'] = array_merge((array) $items[$k]->fields['_were_multiplied'], (array) $item->fields['_were_multiplied']);
                    $items[$k]->fields = (array) $item->fields + (array) $items[$k]->fields;
                } else {
                    $items[$k] = $item;
                }
            }
        }

        return $items;
    }
}


<?php

namespace Arrilot\BitrixModels\Models;

use Arrilot\BitrixModels\Exceptions\ExceptionFromBitrix;
use Arrilot\BitrixModels\Queries\ElementQuery;
use CIBlock;
use Illuminate\Support\Collection;
use LogicException;

/**
 * ElementQuery methods
 * @method static ElementQuery groupBy($value)
 * @method static static getByCode(string $code)
 * @method static static getByExternalId(string $id)
 *
 * Base Query methods
 * @method static Collection|static[] getList()
 * @method static static first()
 * @method static static getById(int $id)
 * @method static ElementQuery sort(string|array $by, string $order='ASC')
 * @method static ElementQuery order(string|array $by, string $order='ASC') // same as sort()
 * @method static ElementQuery filter(array $filter)
 * @method static ElementQuery addFilter(array $filters)
 * @method static ElementQuery resetFilter()
 * @method static ElementQuery navigation(array $filter)
 * @method static ElementQuery select($value)
 * @method static ElementQuery keyBy(string $value)
 * @method static ElementQuery limit(int $value)
 * @method static ElementQuery offset(int $value)
 * @method static ElementQuery page(int $num)
 * @method static ElementQuery take(int $value) // same as limit()
 * @method static ElementQuery forPage(int $page, int $perPage=15)
 * @method static \Illuminate\Pagination\LengthAwarePaginator paginate(int $perPage = 15, string $pageName = 'page')
 * @method static \Illuminate\Pagination\Paginator simplePaginate(int $perPage = 15, string $pageName = 'page')
 * @method static ElementQuery stopQuery()
 * @method static ElementQuery cache(float|int $minutes)
 *
 * Scopes
 * @method static ElementQuery active()
 * @method static ElementQuery sortByDate(string $sort = 'DESC')
 * @method static ElementQuery fromSectionWithId(int $id)
 * @method static ElementQuery fromSectionWithCode(string $code)
 */
class ElementModel extends BitrixModel
{
    /**
     * Corresponding IBLOCK_ID
     *
     * @var int
     */
    const IBLOCK_ID = null;

    /**
     * IBLOCK version (1 or 2)
     *
     * @var int
     */
    const IBLOCK_VERSION = 2;

    /**
     * Bitrix entity object.
     *
     * @var object
     */
    public static $bxObject;

    /**
     * Corresponding object class name.
     *
     * @var string
     */
    protected static $objectClass = 'CIBlockElement';

    /**
     * Iblock PropertiesData from Bitrix DB
     *
     * @var null|array
     */
    protected static $iblockPropertiesData = [];

    /**
     * Have sections been already fetched from DB?
     *
     * @var bool
     */
    protected $sectionsAreFetched = false;

    /**
     * Log in Bitrix workflow ($bWorkFlow for CIBlockElement::Add/Update).
     *
     * @var bool
     */
    protected static $workFlow = false;

    /**
     * Update search after each create or update ($bUpdateSearch for CIBlockElement::Add/Update).
     *
     * @var bool
     */
    protected static $updateSearch = true;

    /**
     * Resize pictures during add/update ($bResizePictures for CIBlockElement::Add/Update).
     *
     * @var bool
     */
    protected static $resizePictures = false;

    /**
     * Getter for corresponding iblock id.
     *
     * @throws LogicException
     *
     * @return int
     */
    public static function iblockId()
    {
        $id = static::IBLOCK_ID;
        if (!$id) {
            throw new LogicException('You must set IBLOCK_ID constant inside a model or override iblockId() method');
        }

        return $id;
    }

    /**
     * Create new item in database.
     *
     * @param $fields
     *
     * @throws LogicException
     *
     * @return static|bool
     * @throws ExceptionFromBitrix
     */
    public static function create($fields)
    {
        if (!isset($fields['IBLOCK_ID'])) {
            $fields['IBLOCK_ID'] = static::iblockId();
        }

        return static::internalCreate($fields);
    }

    public static function internalDirectCreate($bxObject, $fields)
    {
        return $bxObject->add($fields, static::$workFlow, static::$updateSearch, static::$resizePictures);
    }

    /**
     * Fetches static::$iblockPropertiesData if it's not fetched and returns it.
     *
     * @return array
     */
    protected static function getCachedIblockPropertiesData()
    {
        $iblockId = static::iblockId();
        if (!empty(self::$iblockPropertiesData[$iblockId])) {
            return self::$iblockPropertiesData[$iblockId];
        }

        $props = [];
        $dbRes = CIBlock::GetProperties($iblockId, [], []);
        while($property = $dbRes->Fetch()) {
            $props[$property['CODE']] = $property;
        }

        return self::$iblockPropertiesData[$iblockId] = $props;
    }

    /**
     * Setter for self::$iblockPropertiesData[static::iblockId()] mainly for testing.
     *
     * @param $data
     * @return void
     */
    public static function setCachedIblockPropertiesData($data)
    {
        self::$iblockPropertiesData[static::iblockId()] = $data;
    }

    /**
     * Corresponding section model full qualified class name.
     * MUST be overridden if you are going to use section model for this iblock.
     *
     * @throws LogicException
     *
     * @return string
     */
    public static function sectionModel()
    {
        throw new LogicException('public static function sectionModel() MUST be overridden');
    }

    /**
     * Instantiate a query object for the model.
     *
     * @return ElementQuery
     */
    public static function query()
    {
        return new ElementQuery(static::instantiateObject(), get_called_class());
    }

    /**
     * Scope to sort by date.
     *
     * @param ElementQuery $query
     * @param string       $sort
     *
     * @return ElementQuery
     */
    public function scopeSortByDate($query, $sort = 'DESC')
    {
        return $query->sort(['ACTIVE_FROM' => $sort]);
    }

    /**
     * Scope to get only items from a given section.
     *
     * @param ElementQuery $query
     * @param mixed        $id
     *
     * @return ElementQuery
     */
    public function scopeFromSectionWithId($query, $id)
    {
        $query->filter['SECTION_ID'] = $id;

        return $query;
    }

    /**
     * Scope to get only items from a given section.
     *
     * @param ElementQuery $query
     * @param string       $code
     *
     * @return ElementQuery
     */
    public function scopeFromSectionWithCode($query, $code)
    {
        $query->filter['SECTION_CODE'] = $code;

        return $query;
    }

    /**
     * Fill extra fields when $this->field is called.
     *
     * @return null
     */
    protected function afterFill()
    {
        $this->normalizePropertyFormat();
    }

    /**
     * Load all model attributes from cache or database.
     *
     * @return $this
     */
    public function load()
    {
        $this->getFields();

        return $this;
    }

    /**
     * Get element's sections from cache or database.
     *
     * @return array
     */
    public function getSections()
    {
        if ($this->sectionsAreFetched) {
            return $this->fields['IBLOCK_SECTION'];
        }

        return $this->refreshSections();
    }

    /**
     * Refresh model from database and place data to $this->fields.
     *
     * @return array
     */
    public function refresh()
    {
        return $this->refreshFields();
    }

    /**
     * Refresh element's fields and save them to a class field.
     *
     * @return array
     */
    public function refreshFields()
    {
        if ($this->id === null) {
            $this->original = [];
            return $this->fields = [];
        }

        $sectionsBackup = isset($this->fields['IBLOCK_SECTION']) ? $this->fields['IBLOCK_SECTION'] : null;

        $this->fields = static::query()->getById($this->id)->fields;

        if (!empty($sectionsBackup)) {
            $this->fields['IBLOCK_SECTION'] = $sectionsBackup;
        }

        $this->fieldsAreFetched = true;

        $this->original = $this->fields;

        return $this->fields;
    }

    /**
     * Refresh element's sections and save them to a class field.
     *
     * @return array
     */
    public function refreshSections()
    {
        if ($this->id === null) {
            return [];
        }

        $this->fields['IBLOCK_SECTION'] = [];
        $dbSections = static::$bxObject->getElementGroups($this->id, true);
        while ($section = $dbSections->Fetch()) {
            $this->fields['IBLOCK_SECTION'][] = $section;
        }

        $this->sectionsAreFetched = true;

        return $this->fields['IBLOCK_SECTION'];
    }

    /**
     * @deprecated in favour of `->section()`
     * Get element direct section as ID or array of fields.
     *
     * @param bool $load
     *
     * @return false|int|array
     */
    public function getSection($load = false)
    {
        $fields = $this->getFields();
        if (!$load) {
            return $fields['IBLOCK_SECTION_ID'];
        }

        /** @var SectionModel $sectionModel */
        $sectionModel = static::sectionModel();
        if (!$fields['IBLOCK_SECTION_ID']) {
            return false;
        }

        return $sectionModel::query()->getById($fields['IBLOCK_SECTION_ID'])->toArray();
    }

    /**
     * Get element direct section as model object.
     *
     * @param bool $load
     *
     * @return false|SectionModel
     */
    public function section($load = false)
    {
        $fields = $this->getFields();

        /** @var SectionModel $sectionModel */
        $sectionModel = static::sectionModel();

        return $load
            ? $sectionModel::query()->getById($fields['IBLOCK_SECTION_ID'])
            : new $sectionModel($fields['IBLOCK_SECTION_ID']);
    }

    /**
     * Proxy for GetPanelButtons
     *
     * @param array $options
     * @return array
     */
    public function getPanelButtons($options = [])
    {
        return CIBlock::GetPanelButtons(
            static::iblockId(),
            $this->id,
            0,
            $options
        );
    }

    /**
     * Save props to database.
     * If selected is not empty then only props from it are saved.
     *
     * @param array $selected
     *
     * @return bool
     */
    public function saveProps($selected = [])
    {
        $propertyValues = $this->constructPropertyValuesForSave($selected);
        if (empty($propertyValues)) {
            return false;
        }

        $bxMethod = empty($selected) ? 'setPropertyValues' : 'setPropertyValuesEx';
        static::$bxObject->$bxMethod(
            $this->id,
            static::iblockId(),
            $propertyValues
        );

        return true;
    }

    /**
     * Normalize properties's format converting it to 'PROPERTY_"CODE"_VALUE'.
     *
     * @return null
     */
    protected function normalizePropertyFormat()
    {
        if (empty($this->fields['PROPERTIES'])) {
            return;
        }

        foreach ($this->fields['PROPERTIES'] as $code => $prop) {
            $this->fields['PROPERTY_'.$code.'_VALUE'] = $prop['VALUE'];
            $this->fields['~PROPERTY_'.$code.'_VALUE'] = $prop['~VALUE'];
            $this->fields['PROPERTY_'.$code.'_DESCRIPTION'] = $prop['DESCRIPTION'];
            $this->fields['~PROPERTY_'.$code.'_DESCRIPTION'] = $prop['~DESCRIPTION'];
            $this->fields['PROPERTY_'.$code.'_VALUE_ID'] = $prop['PROPERTY_VALUE_ID'];
        }
    }

    /**
     * Construct 'PROPERTY_VALUES' => [...] from flat fields array.
     * This is used in save.
     * If $selectedFields are specified only those are saved.
     *
     * @param $selectedFields
     *
     * @return array
     */
    protected function constructPropertyValuesForSave($selectedFields = [])
    {
        $propertyValues = [];
        $saveOnlySelected = !empty($selectedFields);

        $iblockPropertiesData = static::getCachedIblockPropertiesData();

        if ($saveOnlySelected) {
            foreach ($selectedFields as $code) {
                // if we pass PROPERTY_X_DESCRIPTION as selected field, we need to add PROPERTY_X_VALUE as well.
                if (preg_match('/^PROPERTY_(.*)_DESCRIPTION$/', $code, $matches) && !empty($matches[1])) {
                    $propertyCode = $matches[1];
                    $propertyValueKey = "PROPERTY_{$propertyCode}_VALUE";
                    if (!in_array($propertyValueKey, $selectedFields)) {
                        $selectedFields[] = $propertyValueKey;
                    }
                }

                // if we pass PROPERTY_X_ENUM_ID as selected field, we need to add PROPERTY_X_VALUE as well.
                if (preg_match('/^PROPERTY_(.*)_ENUM_ID$/', $code, $matches) && !empty($matches[1])) {
                    $propertyCode = $matches[1];
                    $propertyValueKey = "PROPERTY_{$propertyCode}_VALUE";
                    if (!in_array($propertyValueKey, $selectedFields)) {
                        $selectedFields[] = $propertyValueKey;
                    }
                }
            }
        }

        foreach ($this->fields as $code => $value) {
            if ($saveOnlySelected && !in_array($code, $selectedFields)) {
                continue;
            }

            if (preg_match('/^PROPERTY_(.*)_VALUE$/', $code, $matches) && !empty($matches[1])) {
                $propertyCode = $matches[1];
                $iblockPropertyData = (array) $iblockPropertiesData[$propertyCode];

                // if file was not changed skip it or it will be duplicated
                if ($iblockPropertyData && $iblockPropertyData['PROPERTY_TYPE'] === 'F' && !empty($this->original[$code]) && $this->original[$code] === $value) {
                    continue;
                }

                // if property type is a list we need to use enum ID/IDs as value/values
                if (array_key_exists("PROPERTY_{$propertyCode}_ENUM_ID", $this->fields)) {
                    $value = $this->fields["PROPERTY_{$propertyCode}_ENUM_ID"];
                } elseif ($iblockPropertyData && $iblockPropertyData['PROPERTY_TYPE'] === 'L' && $iblockPropertyData['MULTIPLE'] === 'Y') {
                    $value = array_keys($value);
                }

                // if property values have descriptions
                // we skip file properties here for now because they cause endless problems. Handle them manually.
                if (array_key_exists("PROPERTY_{$propertyCode}_DESCRIPTION", $this->fields) && (!$iblockPropertyData || $iblockPropertyData['PROPERTY_TYPE'] !== 'F')) {
                    $description = $this->fields["PROPERTY_{$propertyCode}_DESCRIPTION"];

                    if (is_array($value) && is_array($description)) {
                        // for multiple property
                        foreach ($value as $rowIndex => $rowValue) {
                            $propertyValues[$propertyCode][] = [
                                'VALUE' => $rowValue,
                                'DESCRIPTION' => $description[$rowIndex]
                            ];
                        }
                    } else {
                        // for single property
                        $propertyValues[$propertyCode] = [
                            'VALUE' => $value,
                            'DESCRIPTION' => $description
                        ];
                    }
                } else {
                    $propertyValues[$propertyCode] = $value;
                }
            }
        }

        return $propertyValues;
    }

    /**
     * Determine whether the field should be stopped from passing to "update".
     *
     * @param string $field
     * @param mixed  $value
     * @param array  $selectedFields
     *
     * @return bool
     */
    protected function fieldShouldNotBeSaved($field, $value, $selectedFields)
    {
        $blacklistedFields = [
            'ID',
            'IBLOCK_ID',
            'PROPERTIES',
            'PROPERTY_VALUES',
        ];

        return (!empty($selectedFields) && !in_array($field, $selectedFields))
            || in_array($field, $blacklistedFields)
            || ($field[0] === '~');
            //|| (substr($field, 0, 9) === 'PROPERTY_');
    }

    /**
     * @param $fields
     * @param $fieldsSelectedForSave
     * @return bool
     */
    protected function internalUpdate($fields, $fieldsSelectedForSave)
    {
        $fields = $fields ?: [];
        foreach ($fields as $key => $value) {
            if (substr($key, 0, 9) === 'PROPERTY_') {
                unset($fields[$key]);
            }
        }

        $result = !empty($fields) ? static::$bxObject->update($this->id, $fields, static::$workFlow, static::$updateSearch, static::$resizePictures) : false;
        $savePropsResult = $this->saveProps($fieldsSelectedForSave);
        $result = $result || $savePropsResult;

        return $result;
    }

    /**
     * Get value from language field according to current language.
     *
     * @param $field
     * @return mixed
     */
    protected function getValueFromLanguageField($field)
    {
        $key = $field . '_' . $this->getCurrentLanguage() . '_VALUE';

        return isset($this->fields[$key]) ? $this->fields[$key] : null;
    }

    /**
     * @param $value
     */
    public static function setWorkflow($value)
    {
        static::$workFlow = $value;
    }

    /**
     * @param $value
     */
    public static function setUpdateSearch($value)
    {
        static::$updateSearch = $value;
    }

    /**
     * @param $value
     */
    public static function setResizePictures($value)
    {
        static::$resizePictures = $value;
    }
}


<?php

namespace Arrilot\BitrixModels\Queries;

abstract class OldCoreQuery extends BaseQuery
{
    /**
     * Query select.
     *
     * @var array
     */
    public $select = ['FIELDS', 'PROPS'];

    /**
     * Fetch method and parameters.
     *
     * @var array
     */
    protected $fetchUsing;

    /**
     * Constructor.
     *
     * @param object $bxObject
     * @param string $modelName
     */
    public function __construct($bxObject, $modelName)
    {
        parent::__construct($bxObject, $modelName);

        $this->fetchUsing($modelName::$fetchUsing);
    }

    /**
     * Set fetch using from string or array.
     *
     * @param string|array $methodAndParams
     * @return $this
     */
    public function fetchUsing($methodAndParams)
    {
        // simple case
        if (is_string($methodAndParams) || empty($methodAndParams['method'])) {
            $this->fetchUsing = in_array($methodAndParams, ['GetNext', 'getNext'])
                ? ['method' => 'GetNext', 'params' => [true, true]]
                : ['method' => 'Fetch'];

            return $this;
        }

        // complex case
        if (in_array($methodAndParams['method'], ['GetNext', 'getNext'])) {
            $bTextHtmlAuto = isset($methodAndParams['params'][0]) ? $methodAndParams['params'][0] : true;
            $useTilda = isset($methodAndParams['params'][1]) ? $methodAndParams['params'][1] : true;
            $this->fetchUsing = ['method' => 'GetNext', 'params' => [$bTextHtmlAuto, $useTilda]];
        } else {
            $this->fetchUsing = ['method' => 'Fetch'];
        }

        return $this;
    }

    /**
     * Choose between Fetch() and GetNext($bTextHtmlAuto, $useTilda) and then fetch
     *
     * @param \CDBResult $rsItems
     * @return array|false
     */
    protected function performFetchUsingSelectedMethod($rsItems)
    {
        return $this->fetchUsing['method'] === 'GetNext'
            ? $rsItems->GetNext($this->fetchUsing['params'][0], $this->fetchUsing['params'][1])
            : $rsItems->Fetch();
    }
}


<?php

namespace Arrilot\BitrixModels\Models;

use ArrayAccess;
use ArrayIterator;
use Arrilot\BitrixModels\Models\Traits\HidesAttributes;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use IteratorAggregate;

abstract class ArrayableModel implements ArrayAccess, Arrayable, Jsonable, IteratorAggregate
{
    use HidesAttributes;

    /**
     * ID of the model.
     *
     * @var null|int
     */
    public $id;

    /**
     * Array of model fields.
     *
     * @var null|array
     */
    public $fields;

    /**
     * Array of original model fields.
     *
     * @var null|array
     */
    protected $original;

    /**
     * Array of accessors to append during array transformation.
     *
     * @var array
     */
    protected $appends = [];

    /**
     * Array of language fields with auto accessors.
     *
     * @var array
     */
    protected $languageAccessors = [];

    /**
     * Array related models indexed by the relation names.
     *
     * @var array
     */
    public $related = [];

    /**
     * Set method for ArrayIterator.
     *
     * @param $offset
     * @param $value
     *
     * @return void
     */
    public function offsetSet($offset, $value)
    {
        if (is_null($offset)) {
            $this->fields[] = $value;
        } else {
            $this->fields[$offset] = $value;
        }
    }

    /**
     * Exists method for ArrayIterator.
     *
     * @param $offset
     *
     * @return bool
     */
    public function offsetExists($offset)
    {
        return $this->getAccessor($offset) || $this->getAccessorForLanguageField($offset)
            ? true : isset($this->fields[$offset]);
    }

    /**
     * Unset method for ArrayIterator.
     *
     * @param $offset
     *
     * @return void
     */
    public function offsetUnset($offset)
    {
        unset($this->fields[$offset]);
    }

    /**
     * Get method for ArrayIterator.
     *
     * @param $offset
     *
     * @return mixed
     */
    public function offsetGet($offset)
    {
        $fieldValue = isset($this->fields[$offset]) ? $this->fields[$offset] : null;
        $accessor = $this->getAccessor($offset);
        if ($accessor) {
            return $this->$accessor($fieldValue);
        }

        $accessorForLanguageField = $this->getAccessorForLanguageField($offset);
        if ($accessorForLanguageField) {
            return $this->$accessorForLanguageField($offset);
        }

        return $fieldValue;
    }

    /**
     * Get an iterator for fields.
     *
     * @return ArrayIterator
     */
    public function getIterator()
    {
        return new ArrayIterator($this->fields);
    }

    /**
     * Get accessor method name if it exists.
     *
     * @param string $field
     *
     * @return string|false
     */
    private function getAccessor($field)
    {
        $method = 'get'.camel_case($field).'Attribute';

        return method_exists($this, $method) ? $method : false;
    }

    /**
     * Get accessor for language field method name if it exists.
     *
     * @param string $field
     *
     * @return string|false
     */
    private function getAccessorForLanguageField($field)
    {
        $method = 'getValueFromLanguageField';

        return in_array($field, $this->languageAccessors) && method_exists($this, $method) ? $method : false;
    }

    /**
     * Add value to append.
     *
     * @param  array|string  $attributes
     * @return $this
     */
    public function append($attributes)
    {
        $this->appends = array_unique(
            array_merge($this->appends, is_string($attributes) ? func_get_args() : $attributes)
        );

        return $this;
    }

    /**
     * Setter for appends.
     *
     * @param  array  $appends
     * @return $this
     */
    public function setAppends(array $appends)
    {
        $this->appends = $appends;

        return $this;
    }

    /**
     * Cast model to array.
     *
     * @return array
     */
    public function toArray()
    {
        $array = $this->fields;

        foreach ($this->appends as $accessor) {
            if (isset($this[$accessor])) {
                $array[$accessor] = $this[$accessor];
            }
        }

        foreach ($this->related as $key => $value) {
            if (is_object($value) && method_exists($value, 'toArray')) {
                $array[$key] = $value->toArray();
            } elseif (is_null($value) || $value === false) {
                $array[$key] = $value;
            }
        }

        if (count($this->getVisible()) > 0) {
            $array = array_intersect_key($array, array_flip($this->getVisible()));
        }

        if (count($this->getHidden()) > 0) {
            $array = array_diff_key($array, array_flip($this->getHidden()));
        }

        return $array;
    }

    /**
     * Convert model to json.
     *
     * @param int $options
     *
     * @return string
     */
    public function toJson($options = 0)
    {
        return json_encode($this->toArray(), $options);
    }
}


<?php

namespace Arrilot\BitrixModels\Models;

use Illuminate\Database\Eloquent\Model;

class EloquentModel extends Model
{
    /**
     * The primary key for the model.
     *
     * @var string
     */
    protected $primaryKey = 'ID';

    /**
     * The name of the "created at" column.
     *
     * @var string
     */
    const CREATED_AT = 'UF_CREATED_AT';

    /**
     * The name of the "updated at" column.
     *
     * @var string
     */
    const UPDATED_AT = 'UF_UPDATED_AT';

    /**
     * @var array
     */
    public $multipleHighloadBlockFields = [];

    /**
     * Get an attribute from the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        if (in_array($key, $this->multipleHighloadBlockFields)) {
            return unserialize($this->attributes[$key]);
        }

        return parent::getAttribute($key);
    }

    /**
     * Set a given attribute on the model.
     *
     * @param  string  $key
     * @param  mixed  $value
     * @return $this
     */
    public function setAttribute($key, $value)
    {
        if (in_array($key, $this->multipleHighloadBlockFields)) {
            $this->attributes[$key] = serialize($value);

            return $this;
        }

        parent::setAttribute($key, $value);

        return $this;
    }
}