Доменные сущности и ActiveRecord

В этом цикле статей мы уже разобрались с проектированием сущностей, спрограммировали свой собственный репозиторий и изучили использование Doctrine ORM. В этот раз завершим эксперимент и узнаем, можно ли с минимальным ущербом подружить нетривиальный класс доменной сущности с ActiveRecord ORM проекта на Yii2.

Целесообразность

При проектировании сущностей в объектно-ориентированной парадигме для контроля за бизнес-логикой мы разрабатываем классы с инкапсуляцией данных и поведения внутри них. Как мы упоминали, подход Code First оказывается удобным для разработки, но бывает не очень простым в момент привязки к базам данных. Сокрытие значений в приватных полях, вложенные объекты и собственные типы данных требуют написания преобразователей (мапперов) значений в поля БД и ручного слежения за связями объектов внутри агрегата.

Написание собственного репозитория на SQL-запросах – задача интересная, но весьма трудная для программиста, не обладающего достаточным уровнем знаний и опыта. Особенно если он ещё не использовал сторонние ORM, из общения с которыми мог собрать список их сильных и слабых сторон.

В качестве быстрого решения можно подключить к проекту ту же Doctrine ORM и получить полноценный Data Mapper «из коробки». Но что если не хочется подключать сторонние тяжеловесные библиотеки? Можно ли реализовать полноценные сущности поверх ActiveRecord?

Сейчас у нас имеется PHP-сущность Employee:

class Employee implements AggregateRoot
{
    use EventTrait;
 
    private $id;
    private $name;
    private $address;
    private $phones;
    private $createDate;
    private $statuses = [];
 
    public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones)
    {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phones = new Phones($phones);
        $this->createDate = $date;
        $this->addStatus(Status::ACTIVE, $date);
        $this->recordEvent(new Events\EmployeeCreated($this->id));
    }
 
    ...
 
    public function getId(): Id { return $this->id; }
    public function getName(): Name { return $this->name; }
    public function getPhones(): array { return $this->phones->getAll(); }
    public function getAddress(): Address { return $this->address; }
    public function getCreateDate(): \DateTimeImmutable { return $this->createDate; }
    public function getStatuses(): array { return $this->statuses; }
}

Наш прошлый SqlEmployeeRepository и Doctrine ORM объединяет то, что вся работа по обслуживанию (преобразования, SQL-запросы) там производится снаружи сущности объектом $repository или $entityManager:

$employee = new Employee(...);
$repository->add($employee);
...
$employee = $repository->get($id);
$employee->rename('New Name');
$repository->save($employee);
...
$employee = $repository->get($id);
$repository->remove($employee);

В итоге сущность остаётся чистой. Всю «грязную» работу совершает репозиторий.

В отличие же от любого внешнего преобразователя паттерн ActiveRecord представляет из себя другой подход. Каждый объект представляет из себя строку из БД, даёт доступ к её полям и целиком носит все данные и всю функциональность внутри себя:

$employee = new Employee(...);
$employee->save();
...
$employee = Employee::findOne($id);
$employee->name = 'New Name';
$employee->save();
...
$employee = Employee::findOne($id);
$employee->delete();

По этому принципу работает ActiveRecord в Yii, Eloquent в Laravel, а также сторонние библиотеки вроде Propel ORM. Они предоставляют лёгкий доступ к данным: все поля из таблицы в БД становятся публичными свойствами объекта.

Такая архитектура с внутренней работой удобна тем, что изнутри объект имеет доступ ко всем своим приватным полям. Не нужно создавать отдельные классы репозиториев и использовать рефлексию.

Но неудобно тем, что все вспомогательные преобразования данных нужно вписывать туда же внутрь. В итоге при отсутствии дисциплины у разработчика такой класс Employee может оказаться большим и неповоротливым.

Помимо простого доступа к данным у ActiveRecord-систем могут быть и другие встроенные возвожности:

  • Встроенная поддержка связей через внешние ключи таблиц.
  • Встроенное слежение за изменениями значений (Dirty Attributes): если значения полей не изменились, то лишний запрос в БД не пойдёт.
  • Встроенная реализация оптимистической блокировки (Optimistic Lock), если хочется защититься от одновременного изменения одной и той же строки.

Если дополнить это автоматическим слежением за изменением связей и их автосохранением, то можно получить вполне удобную систему.

Интеграция ActiveRecord

В Yii2 класс ActiveRecord используют не только для сохранения, но и для валидации и кучи прочих встроенных вещей... Но мы не будем смешивать обязанности и будем использовать этот класс только как инструмент для работы со строками в БД.

Первым делом, отнаследуем нашу сущность от класса yii\db\ActiveRecord, в конструктор добавим вызов parent::__construct() (или вместо него можно самостоятельно вызвать $this->init(), запускающий системное событие ActiveRecord::EVENT_INIT) и добавим метод tableName для указания имени таблицы в БД:

namespace app\entities\Employee;
 
use app\entities\AggregateRoot;
use app\entities\Employee\Events;
use app\entities\EventTrait;
use yii\db\ActiveRecord;
 
class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait;
 
    private $id;
    private $name;
    private $address;
    private $phones;
    private $createDate;
    private $statuses = [];
 
    public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones)
    {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phones = new Phones($phones);
        $this->createDate = $date;
        $this->addStatus(Status::ACTIVE, $date);
        $this->recordEvent(new Events\EmployeeCreated($this->id));
        parent::__construct();
    }
 
    ...
 
    ######## INFRASTRUCTURE #########
 
    public static function tableName(): string
    {
        return '{{%ar_employees}}';
    }
}

Аналогично модифицируем классы Phone и Status и приступим к дальнейшей интеграции к AR.

Конструкторы

Сейчас мы можем создавать сотрудника со всеми требуемыми данными через его конструктор:

$employee = new Employee($id, $name, ...);

Это удобно для нас, но пока неудобно для фреймворка.

Рассмотрим варианты того, как мы можем подружить его с такими конструкторами или как мы сможем их эмулировать с помощью статических методов. Начнём с настоящих конструкторов.

Как фреймворк внутри работает с нашими классами? Понятно, что созданный нами объект можно будет сохранять вызовом $employee->save(), так как ничего этому не мешает. Но что происходит при поиске записей по первичному ключу вызовом Employee::findOne($id)?

Это равноценно более длинному вызову:

$employee = Employee::find()->andWhere(['id' => $id])->one();

Статический метод find фактически создаёт объект new ActiveQuery(get_called_class()):

class ActiveRecord extends BaseActiveRecord
{
    public static function find()
    {
        return Yii::createObject(ActiveQuery::className(), [get_called_class()]);
    }
}

и далее мы уже вызываем методы andWhere и one от объекта класса ActiveQuery. Методы andWhere, limit, orderBy и подобные только модифицируют запрос. Нам интересен только последний ключевой метод one, который запускает сформированный запрос и возвращает найденный объект:

class ActiveQuery extends Query implements ActiveQueryInterface
{
    ...
 
    public function one($db = null)
    {
        $row = parent::one($db);
        if ($row !== false) {
            $models = $this->populate([$row]);
            return reset($models) ?: null;
        } else {
            return null;
        }
    }
 
    ...
}

Он вызывает метод one родительского класса Query для извлечения строки из БД и передаёт её в метод populate:

class ActiveQuery extends Query implements ActiveQueryInterface
{
    use ActiveQueryTrait;
    use ActiveRelationTrait;
 
    ...
 
    public function populate($rows)
    {
        if (empty($rows)) {
            return [];
        }
 
        $models = $this->createModels($rows);
        if (!empty($this->join) && $this->indexBy === null) {
            $models = $this->removeDuplicatedModels($models);
        }
        if (!empty($this->with)) {
            $this->findWith($this->with, $models);
        }
 
        if ($this->inverseOf !== null) {
            $this->addInverseRelations($models);
        }
 
        if (!$this->asArray) {
            foreach ($models as $model) {
                $model->afterFind();
            }
        }
 
        return $models;
    }
 
    ...
}

И он уже здесь вызывает метод createModels из подключенного трейта ActiveQueryTrait:

namespace yii\db;
 
trait ActiveQueryTrait
{
    public $modelClass;
    public $asArray;
 
    ...
 
    protected function createModels($rows)
    {
        $models = [];
        if ($this->asArray) {
            ...
        } else {
            $class = $this->modelClass;
            if ($this->indexBy === null) {
                foreach ($rows as $row) {
                    $model = $class::instantiate($row);
                    $modelClass = get_class($model);
                    $modelClass::populateRecord($model, $row);
                    $models[] = $model;
                }
            } else {
                foreach ($rows as $row) {
                    $model = $class::instantiate($row);
                    $modelClass = get_class($model);
                    $modelClass::populateRecord($model, $row);
                    if (is_string($this->indexBy)) {
                        $key = $model->{$this->indexBy};
                    } else {
                        $key = call_user_func($this->indexBy, $model);
                    }
                    $models[$key] = $model;
                }
            }
        }
 
        return $models;
    }
 
    ...
}

Вот мы и докопались до процесса преобразования голых данных из БД в ActiveRecord-объекты. При вызове того самого new ActiveQuery(get_called_class()) этот текущий класс Employee из get_called_class записывается в $this->modelClass. Вместо вызова $model = new $class вызывается статический метод $class::instantiate($row), а потом уже к созданный объект передаётся в вызов populateRecord для заполнения полей объекта значениями из БД. Оба этих метода находятся в классе BaseActiveRecord:

abstract class BaseActiveRecord extends Model implements ActiveRecordInterface
{
    ...
 
    public static function instantiate($row)
    {
        return new static();
    }
 
    public static function populateRecord($record, $row)
    {
        ...
    }    
    ...
}

По умолчанию метод instantiate будет вызывать new Employee() и PHP будет ругаться на вызов без аргументов конструктора. При желании мы можем переопределить этот метод в своей сущности, чтобы изменить процесс конструирования. Например, instantiate активно используют для реализации наследования с одной таблицей, когда мы имеем несколько наследников одной сущности и хотим налету выбирать класс в зависимости от значения поля type в БД:

class Car extends ActiveRecord
{
    public static function instantiate($row)
    {
        switch ($row['type']) {
            case SportCar::TYPE:
                return new SportCar();
            case HeavyCar::TYPE:
                return new HeavyCar();
            default:
               return new self;
        }
    }
 
    ...
}

Но, как мы говорили ещё при разработке нативного репозитория, наш конструктор нельзя использовать при извлечении записи из БД, так как он проставляет дату и записывает событие создания. Там мы использовали рефлексию и даже написали свой гидратор. Соответственно, аналогичным путём мы можем «обмануть» фреймворк: для пропуска конструктора в методе instantiate использовать искусственное создание через рефлексию:

class Employee extends ActiveRecord implements AggregateRoot
{    
    ...
 
    public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones)
    {
        ...
        parent::__construct();
    }
 
    ...
 
    ######## INFRASTRUCTURE #########
 
    public static function tableName(): string
    {
        return '{{%ar_employees}}';
    }
 
    public static function instance($refresh = false): self
    {
        return self::instantiate([]);
    }
 
    public static function instantiate($row): self
    {
        $class = get_called_class();
        $object = new \ReflectionClass($class)->newInstanceWithoutConstructor();
        $object->init();
        return $object;
    }
}

Фреймворк использует метод instantiate() при восстановлении существующей записи из базы данных. Но помимо него существует и метод instance(), который фреймворк использует для служебного создания объекта когда хочет вызвать attributeLabels() или исследовать связи для жадной загрузки по joinWith(). Поэтому мы должны пеопределить и метод instance().

Для ускорения работы можно закешировать объект new \ReflectionClass($class) и инстанс в статические переменные. И можно заменить рефлексию на подход с десериализацией пустого объекта, подсказанный здесь. В итоге это можно реализовать так:

public static function instance($refresh = false): self
{
    static $instance;
    return $refresh || !$instance ? $instance = self::instantiate([]) : $instance;
}
 
public static function instantiate($row): self
{
    static $prototype;
    if ($prototype === null) {
        $class = \get_called_class();
        $prototype = unserialize(sprintf('O:%d:"%s":0:{}', \strlen($class), $class));
    }
    $object = clone $prototype;
    $object->init();
    return $object;
}

В первый раз мы создаём голый объект. При последующих вызовах клонируем его (клонирование производится быстрее десериализации) и вручную вызываем служебный метод init. На этом пока с Employee всё.

Аналогично нужно добавить наследование от ActiveRecord с вызовом parent::__construct() и переопределить методы instantiate в классе Phone:

class Phone extends ActiveRecord
{
    ...
 
    public function __construct($country, $code, $number)
    {
        ...
        parent::__construct();
    }
 
    ...
 
    ######## INFRASTRUCTURE #########
 
    public static function tableName(): string
    {
        return '{{%ar_employee_phones}}';
    }
 
    public static function instance($refresh = false): self
    {
        static $instance;
        return $refresh || !$instance ? $instance = self::instantiate([]) : $instance;
    }
 
    public static function instantiate($row): self
    {
        static $prototype;
        if ($prototype === null) {
            $class = \get_called_class();
            $prototype = unserialize(sprintf('O:%d:"%s":0:{}', \strlen($class), $class));
        }
        $object = clone $prototype;
        $object->init();
        return $object;
    }
}

и в Status.

И, чтобы не копировать метод instantiate в каждый объект, его оптимизированную версию можно вынести в общий InstantiateTrait:

namespace app\repositories;
 
trait InstantiateTrait
{
    private static $_instance;
    private static $_prototype;
 
    public static function instance($refresh = false): self
    {
        if ($refresh || self::$_prototype === null) {
            self::$_instance = self::instantiate([]);
        }
        return self::$_instance;
    }
 
    public static function instantiate($row): self
    {
        if (self::$_prototype === null) {
            $class = \get_called_class();
            self::$_prototype = unserialize(sprintf('O:%d:"%s":0:{}', \strlen($class), $class));
        }
        $entity = clone self::$_prototype;
        $entity->init();
        return $entity;
    }
}

и подключать ко всем классам:

use app\repositories\InstantiateTrait;
 
class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait;
    ...
    public function __construct(Id $id, Name $name, Address $address, array $phones)
    ...
}
 
class Phone extends ActiveRecord
{
    use InstantiateTrait;
    ...
    public function __construct(int $country, string $code, string $number)
    ...
}
 
class Status extends ActiveRecord
{
    use InstantiateTrait;
    ...
    public function __construct(string $value, \DateTimeImmutable $date)
    ...
}

Теперь при использованием фреймворком наших сущностей никаких ошибок не будет.

Альтернативный путь

Нам повезло, что хоть и с небольшими «хаками» нам удалось подружить Yii с нашими переопределёнными конструкторами. Но не все фреймворки и ORM-библиотеки такие дружелюбные.

Если фреймворк не позволяет переопределять конструкторы, то мы можем воспользоваться альтернативным вариантом с написанием статического метода взамен конструктора. То есть мы вместо __construct:

class Post extends ActiveRecord
{
    use InstantiateTrait;
 
    public function __construct($title, $text)
    {
        $this->title = $title;
        $this->text = $text;
        $this->created_at = time();
        parent::__construct();
    }
 
    ...
}

просто вписываем немного модифицированный статический метод, конструирующий новый объект. Например, метод create:

class Post extends ActiveRecord
{
    public static function create($title, $text)
    {
        $post = new self();
        $post->title = $title;
        $post->text = $text;
        $post->created_at = time();
        return $post;
    }
 
    ...
}

Мы здесь не работаем с $this, а создаём объект $post = new Post(), заполняем его поля и возвращаем. Получился некий статический метод-фабрика.

В Eloquent-библиотеке от Laravel метод create уже занят, поэтому придумайте другое имя.

При этом поля могут спокойно быть приватными. Здесь мы пользуемся той особенностью областей видимости, что смысл высказывания «модификатор private делает поле доступным только внутри класса» применИм ко всем объектам данного класса, а не только к $this. То есть у нас есть доступ не только к полю $this->title текущего объекта, но и к приватному полю $post->title чужого. Даже в статическом методе.

Такой прямой доступ к приватным полям немного нарушает инкапсуляцию, но это можно потерпеть. При таком подходе вместо привычного вызова:

$post = new Post('Title', 'Text');

везде нужно не забывать использовать конструирование через новый статический метод:

$post = Post::create('Title', 'Text');

Это некий компромисс, на который мы идём для совместимости с фреймворком.

Приватные поля и объекты-значения

Мы научились переопределять instantiate, чтобы «заглушить» конструктор при поиске сущностей в базе, теперь нам нужно перейти к заполнению наших приватных полей данными из БД и сохранению их обратно.

Если бы мы сгенерировали модель данных Status по структуре базы, то бы получили такой класс:

/**
 * @property integer $id
 * @property string $value
 * @property string $date
 */
class Status extends ActiveRecord
{
    public static function tableName(): string
    {
        return '{{%ar_employee_statuses}}';
    }
}

Свойства здесь прописаны в PHPDoc-блоке только для работы автоподстановки полей в IDE. Сами же поля будут считаны из таблицы в БД в приватный массив $_attributes и доступны посредством работы магических методов __get и __set: при обращении к $this->value мы на самом деле работаем с $this->_attributes['value'].

С таким объектом удобно работать для быстрого доступа к полям таблицы, но неудобно с точки зрения разработки сущностей. Есть недостатки, вытекающие из того, что ActiveRecord – это примитивное объектное отображение строки из таблицы в БД:

  • Полями объекта являются колонки из таблицы.
  • Все поля являются публичными.
  • Типы полей объекта совпадают с типами в БД.
  • Нет поддержки вложенных полей.

Отсутствует преобразование типов данных: если в БД используем колонку типа DATETIME, то в поле $status->date получаем дату в виде строки. И при переименовании колонки или смене её типа нужно переписывать весь использующий её код.

У наших же сущностей немного другая структура. Даже наш класс Status:

class Status extends ActiveRecord
{    
    private $value;
    private $date;
 
    ...
 
    public function __construct(string $value, \DateTimeImmutable $date)
    {
        Assertion::inArray($value, [
            self::ACTIVE,
            self::ARCHIVED
        ]);
        $this->value = $value;
        $this->date = $date;
        parent::__construct();
    }
 
    public function getValue(): string { return $this->value; }
    public function getDate(): \DateTimeImmutable { return $this->date; }
 
    ######## INFRASTRUCTURE #########
 
    public static function tableName()
    {
        return '{{%ar_employee_statuses}}';
    }
}

не подпадает под примитивное отображение строки из БД, так как:

  • имеет приватные поля, закрытые от прямого внешнего доступа;
  • хранит дату в виде вложенного объекта класса DateTimeImmutable.

Если у нас в БД в таблице employee_statuses будут такие же поля value и date, то возникнет путаница.

Что нужно сделать, чтобы наш DateTimeImmutable умел преобразовываться в строку для сохранения в колонку DATETIME и из строки с датой обратно в объект? Можно просто переименовать поля в таблице БД и самим преобразовывать значения перед сохранением из реальных полей объекта в суррогатные поля таблицы и обратно при извлечении из базы. Для этого нам пригодятся события afterFind и beforeSave:

/**
 * @property integer $status_id
 * @property string $status_value
 * @property string $status_date
 */
class Status extends ActiveRecord
{
    private $value;
    private $date;
 
    ...
 
    ######## INFRASTRUCTURE #########
 
    ...
 
    public function afterFind(): void
    {
        $this->value = $this->status_value;
        $this->date = new \DateTimeImmutable($this->status_date);
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->status_value = $this->value;
        $this->status_date = $this->date->format('Y-m-d H:i:s');
 
        return parent::beforeSave($insert);
    }
}

Поле value в объекте у нас перегоняется в колонку status_value в БД.

Или, для производительности и чтобы убрать PHPDoc без появления проблем с подсказками «Свойство status_value не найдено в классе Status» в IDE, можно использовать напрямую встроенные методы getAttribute и setAttribute:

class Status extends ActiveRecord
{
    ...
 
    public function afterFind(): void
    {
        $this->value = $this->getAttribute('status_value');
        $this->date = new \DateTimeImmutable($this->getAttribute('status_date'));
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->setAttribute('status_value', $this->value);
        $this->setAttribute('status_date', $this->date->format('Y-m-d H:i:s'));
 
        return parent::beforeSave($insert);
    }
}

Такие же блоки нужно добавить в Phone:

class Phone extends ActiveRecord
{
    ...
 
    public function afterFind(): void
    {
        $this->country = $this->getAttribute('phone_country');
        $this->code = $this->getAttribute('phone_code');
        $this->number = $this->getAttribute('phone_number');
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->setAttribute('phone_country', $this->country);
        $this->setAttribute('phone_code', $this->code);
        $this->setAttribute('phone_number', $this->number);
 
        return parent::beforeSave($insert);
    }
}

И теперь по аналогии мы можем восстанавливать из базы данных любые комбинации объектов внутри Employee:

class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait;
 
    private $id;
    private $name;
    private $address;
    private $phones;
    private $createDate;
    private $statuses = [];
 
    ...
 
    ######## INFRASTRUCTURE #########
 
    public static function tableName(): string
    {
        return '{{%ar_employees}}';
    }
 
    public function afterFind(): void
    {
        $this->id = new Id(
            $this->getAttribute('employee_id')
        );
 
        $this->name = new Name(
            $this->getAttribute('employee_name_last'),
            $this->getAttribute('employee_name_first'),
            $this->getAttribute('employee_name_middle')
        );
 
        $this->address = new Address(
            $this->getAttribute('employee_address_country'),
            $this->getAttribute('employee_address_region'),
            $this->getAttribute('employee_address_city'),
            $this->getAttribute('employee_address_street'),
            $this->getAttribute('employee_address_house')
        );
 
        $this->createDate = new \DateTimeImmutable(
            $this->getAttribute('employee_create_date')
        );
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->setAttribute('employee_id', $this->id->getId());
 
        $this->setAttribute('employee_name_last', $this->name->getLast());
        $this->setAttribute('employee_name_first', $this->name->getFirst());
        $this->setAttribute('employee_name_middle', $this->name->getMiddle());
 
        $this->setAttribute('employee_address_country', $this->address->getCountry());
        $this->setAttribute('employee_address_region', $this->address->getRegion());
        $this->setAttribute('employee_address_city', $this->address->getCity());
        $this->setAttribute('employee_address_street', $this->address->getStreet());
        $this->setAttribute('employee_address_house', $this->address->getHouse());
 
        $this->setAttribute('employee_create_date', $this->getCreateDate()->format('Y-m-d H:i:s'));
 
        $this->setAttribute('employee_current_status', $this->getCurrentStatus()->getValue());
 
        return parent::beforeSave($insert);
    }
}

Осталось подключить $this->phones и $this->statuses.

Связи

В ActiveRecord внутри Yii2 связи объявляются в виде геттеров:

class Post extends ActiveRecord
{
    ...
 
    public function getCategory(): ActiveQuery
    {
        return $this->hasOne(Category::className(), ['id' => 'category_id']);
    }
 
    public function getPhotos(): ActiveQuery
    {
        return $this->hasMany(Photo::className(), ['post_id' => 'id']);
    }
}

И мы бы могли подумать, что давайте также и сделаем наши getPhones() и getStatuses()... Но нет. В Yii методы связей возвращают не значения, а объекты ActiveQuery. И снаружи наши тесты с таким getStatuses работать не будут, так как для получения именно массива нужно добавить all() или вызвать геттер как виртуальное поле:

var_dump($employee->getStatuses()); // object(ActiveQuery)
var_dump($employee->getStatuses()->all()); // array(...)
var_dump($employee->statuses); // array(...)

Поэтому по аналогии с суррогатными полями сделаем суррогатные связи и будем их значения также присваивать приватным полям в afterFind:

class Employee extends ActiveRecord implements AggregateRoot
{
    ...
 
    private $phones;
    private $statuses = [];
 
    public function afterFind(): void
    {
        $this->id = new Id(
            $this->getAttribute('employee_id')
        );
 
        ...
 
        $this->phones = new Phones($this->relatedPhones);
        $this->statuses = $this->relatedStatuses;
 
        parent::afterFind();
    }
 
    public function getRelatedPhones(): ActiveQuery
    {
        return $this->hasMany(Phone::className(), ['phone_employee_id' => 'employee_id'])->orderBy('phone_id');
    }
 
    public function getRelatedStatuses(): ActiveQuery
    {
        return $this->hasMany(Status::className(), ['status_employee_id' => 'employee_id'])->orderBy('status_id');
    }
}

Мы сразу присваиваем и телефоны, и статусы. Это уже не будет ленивой загрузкой. Но эту проблему вскоре решим.

С чтением связей понятно. Осталось решить вопрос с их сохранением. В отличие от Doctrine ORM все связи в Yii определяются геттерами:

class Post extends ActiveRecord
{
    ...
 
    public function getCategory(): ActiveQuery
    {
        return $this->hasOne(Category::className(), ['id' => 'category_id']);
    }
 
    public function getPhotos(): ActiveQuery
    {
        return $this->hasMany(Photo::className(), ['post_id' => 'id']);
    }
}

и считываются по соответсвующему имени связи:

$post = Post::findOne(5);
 
echo $post->category->title;
 
foreach ($post->photos as $photo) {
    echo $photo->file;
}

При вызове несуществующего поля category магическипй метод __get считывает геттер getCategory() и, если это именно связь, а не просто геттер, осуществляет запрос в БД. Связи доступны только для чтения и возврящают массивы, а не коллекции. Мы не можем по этому псевдосвойству ничего изменить вроде такого:

$post->category = $category;
$post->photos[] = $photo;
unset($post->photos[$i]);

Для добавления или удаления связанных объектов предусмотрены встроенные методы link и unlink. С ними мы могли бы написать код так:

class Post extends ActiveRecord
{
    ...
 
    public function addPhoto(Photo $photo): void
    {
        $this->link('photos', $photo);
    }
 
    public function removePhoto($id): void
    {
        foreach ($this->photos as $photo) {
            if ($photo->id === $id) {
                $this->unlink('photos', $photo, true);
            }
        }
    }
 
    public function getPhotos(): ActiveQuery
    {
        return $this->hasMany(Photo::className(), ['post_id' => 'id']);
    }
}

и работать так:

$post = new Post();
 
$db->transaction(function() use ($post) {
    $post->save();
    $post->addPhoto(new Photo(...));
    $post->addPhoto(new Photo(...));
});

Нам нужно сначала сохранить $post, чтобы метод link мог присвоить значение post_id и сохранил $photo.

Но нам бы хотелось использовать отложенное сохранение. То есть сначала заполнить все данные и связи, а потом сохранить методом save целиком всю сущность:

$post = new Post();
 
// Присваиваем в приватные поля
$post->addPhoto(new Photo(...));
$post->addPhoto(new Photo(...));
 
$db->transaction(function() use ($post) {
    // Сохраняем всё вместе
    $post->save();
});

Именно такой подход с отложенным сохранением нам необходим, чтобы перенести транзакцию с сохранением всей сущности в репозиторий.

Поэтому для добавления фотографий можно использовать встроенный метод populateRelation и производить сохранение телефонов в БД методом link в методе afterSave сущности:

class Post extends ActiveRecord
{
    ...
 
    public function addPhoto(Photo $photo): void
    {
        $photos = $this->photos;
        $photos[] = $photo;
        $this->populateRelation('photos', $photos);
    }
 
    public function removePhoto($id): void
    {
        $photos = $this->photos;
        foreach ($photos as $i => $photo) {
            if ($photo->id === $id) {
                unset($photos[$i]);
            }
        }
        $this->populateRelation('photos', $photos);
    }
 
    public function getPhotos(): ActiveQuery
    {
        return $this->hasMany(Photo::className(), ['post_id' => 'id']);
    }
 
    public function afterSave($insert, $changedAttributes): void
    {
        $relatedRecords = $this->getRelatedRecords();
 
        if (isset($relatedRecords['photos'])) {
            foreach ($relatedRecords['photos'] as $photo) {
                $this->link('photos', $photo);
            }
        }
 
        ...
 
        parent::afterSave($insert, $changedAttributes);
    }
}

А что нужно сделать для поддержки удаления фотографий в методе removePhoto? Для этого можно сделать хитрый сеттер setPhotos (который будет сохранять изначальный массив фотографий в приватную переменную $_oldRelations) и присваивать их в $this->photos (что в Yii вызовет этот сеттер setPhotos):

/**
 * @propery Photo[] $photos
 */
class Post extends ActiveRecord
{
    ...
 
    private $_oldRelations = [];
 
    public function addPhoto(Photo $photo): void
    {
        $photos = $this->photos;
        $photos[] = $photo;
        $this->photos = $photos;
    }
 
    public function removePhoto($id): void
    {
        $photos = $this->photos;
        foreach ($photos as $i => $photo) {
            if ($photo->id === $id) {
                unset($photos[$i]);
            }
        }
        $this->photos = $photos;
    }
 
    public function getPhotos(): ActiveQuery
    {
        return $this->hasMany(Photo::className(), ['post_id' => 'id']);
    }
 
    private function setPhotos(array $photos): void
    {
        if (!isset($this->_oldRelationValue['photos'])) {
            $this->_oldRelations['photos'] = $this->photos;
        }
        $this->populateRelation('photos', $photos);
    }
 
    public function afterSave($insert, $changedAttributes): void
    {        
        ...
 
        parent::afterSave($insert, $changedAttributes);
    }
}

и в методе afterSave теперь сравнивать новый массив связей со старым, чтобы прилинковать новые фотографии и отлинковать удалённые.

Чтобы не изобретать такую систему слежения мы можем взять готовое поведение yii2-save-relations-behavior:

composer require la-haute-societe/yii2-save-relations-behavior

И подключить его к нашему классу Employee, указав слежение за связями relatedPhones и relatedStatuses:

use lhs\Yii2SaveRelationsBehavior\SaveRelationsBehavior;
 
class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait;
 
    ...
 
    ######## INFRASTRUCTURE #########
 
    public static function tableName(): string
    {
        return '{{%ar_employees}}';
    }
 
    public function behaviors(): array
    {
        return [
            [
                'class' => SaveRelationsBehavior::className(),
                'relations' => ['relatedPhones', 'relatedStatuses'],
            ],
        ];
    }
 
    public function transactions(): array
    {
        return [
            self::SCENARIO_DEFAULT => self::OP_ALL,
        ];
    }
 
    ...
}

Теперь нам достаточно вызвать только $employee->save(), и весь агрегат сохранится целиком с сохранением привязанных телефонов и статусов. Дополнительно методом transactions мы указали фреймворку и этому поведению оборачивать все операции в транзакцию для сохранения целостности.

Модифицированная сущность

Посмотрим, что у нас получилось. Мы добавили подержку конструкторов (с помощью переопределения метода instantiate), вложенных объектов Name и Address (написав преобразования в afterFind и beforeSave) и автосохранение связанных телефонов и статусов (с помощью готового поведения). И класс стал таким:

class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait;
 
    private $id;
    private $name;
    private $address;
    private $phones;
    private $createDate;
    private $statuses = [];
 
    public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones)
    {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phones = new Phones($phones);
        $this->createDate = $date;
        $this->addStatus(Status::ACTIVE, $date);
        $this->recordEvent(new Events\EmployeeCreated($this->id));
        parent::__construct();
    }
 
    public function rename(Name $name): void
    {
        $this->name = $name;
        $this->recordEvent(new Events\EmployeeRenamed($this->id, $name));
    }
 
    public function changeAddress(Address $address): void
    {
        $this->address = $address;
        $this->recordEvent(new Events\EmployeeAddressChanged($this->id, $address));
    }
 
    public function addPhone(Phone $phone): void
    {
        $this->phones->add($phone);
        $this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone));
    }
 
    public function removePhone($index): void
    {
        $phone = $this->phones->remove($index);
        $this->recordEvent(new Events\EmployeePhoneRemoved($this->id, $phone));
    }
 
    public function archive(\DateTimeImmutable $date): void
    {
        if ($this->isArchived()) {
            throw new \DomainException('Employee is already archived.');
        }
        $this->addStatus(Status::ARCHIVED, $date);
        $this->recordEvent(new Events\EmployeeArchived($this->id, $date));
    }
 
    public function reinstate(\DateTimeImmutable $date): void
    {
        if (!$this->isArchived()) {
            throw new \DomainException('Employee is not archived.');
        }
        $this->addStatus(Status::ACTIVE, $date);
        $this->recordEvent(new Events\EmployeeReinstated($this->id, $date));
    }
 
    public function remove(): void
    {
        if (!$this->isArchived()) {
            throw new \DomainException('Cannot remove active employee.');
        }
        $this->recordEvent(new Events\EmployeeRemoved($this->id));
    }
 
    public function isActive(): bool
    {
        return $this->getCurrentStatus()->isActive();
    }
 
    public function isArchived(): bool
    {
        return $this->getCurrentStatus()->isArchived();
    }
 
    private function getCurrentStatus(): Status
    {
        return end($this->statuses);
    }
 
    private function addStatus($value, \DateTimeImmutable $date): void
    {
        $this->statuses[] = new Status($value, $date);
    }
 
    public function getId(): Id { return $this->id; }
    public function getName(): Name { return $this->name; }
    public function getPhones(): array { return $this->phones->getAll(); }
    public function getAddress(): Address { return $this->address; }
    public function getCreateDate(): \DateTimeImmutable { return $this->createDate; }
    public function getStatuses(): array { return $this->statuses; }
 
    ######## INFRASTRUCTURE #########
 
    public static function tableName(): string
    {
        return '{{%ar_employees}}';
    }
 
    public function behaviors(): array
    {
        return [
            [
                'class' => SaveRelationsBehavior::className(),
                'relations' => ['relatedPhones', 'relatedStatuses'],
            ],
        ];
    }
 
    public function transactions(): array
    {
        return [
            self::SCENARIO_DEFAULT => self::OP_ALL,
        ];
    }
 
    public function afterFind(): void
    {
        $this->id = new Id(
            $this->getAttribute('employee_id')
        );
 
        $this->name = new Name(
            $this->getAttribute('employee_name_last'),
            $this->getAttribute('employee_name_first'),
            $this->getAttribute('employee_name_middle')
        );
 
        $this->address = new Address(
            $this->getAttribute('employee_address_country'),
            $this->getAttribute('employee_address_region'),
            $this->getAttribute('employee_address_city'),
            $this->getAttribute('employee_address_street'),
            $this->getAttribute('employee_address_house')
        );
 
        $this->createDate = new \DateTimeImmutable(
            $this->getAttribute('employee_create_date')
        );
 
        $this->phones = new Phones($this->relatedPhones);
        $this->statuses = $this->relatedStatuses;
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->setAttribute('employee_id', $this->id->getId());
 
        $this->setAttribute('employee_name_last', $this->name->getLast());
        $this->setAttribute('employee_name_first', $this->name->getFirst());
        $this->setAttribute('employee_name_middle', $this->name->getMiddle());
 
        $this->setAttribute('employee_address_country', $this->address->getCountry());
        $this->setAttribute('employee_address_region', $this->address->getRegion());
        $this->setAttribute('employee_address_city', $this->address->getCity());
        $this->setAttribute('employee_address_street', $this->address->getStreet());
        $this->setAttribute('employee_address_house', $this->address->getHouse());
 
        $this->setAttribute('employee_create_date', $this->getCreateDate()->format('Y-m-d H:i:s'));
 
        $this->setAttribute('employee_current_status', $this->getCurrentStatus()->getValue());
 
        $this->relatedPhones = $this->phones->getAll();
        $this->relatedStatuses = $this->statuses;
 
        return parent::beforeSave($insert);
    }
 
    public function getRelatedPhones(): ActiveQuery
    {
        return $this->hasMany(Phone::className(), ['phone_employee_id' => 'employee_id'])->orderBy('phone_id');
    }
 
    public function getRelatedStatuses(): ActiveQuery
    {
        return $this->hasMany(Status::className(), ['status_employee_id' => 'employee_id'])->orderBy('status_id');
    }
}

В итоге сущность Employee сверху никак не изменилась. Только обросла инфраструктурными элементами снизу.

В Yii2 обычно не любят тестировать ActiveRecord-классы, так как список полей и их типы никак не запрограммированы в самом классе. При любой попытке прочитать или записать любое из полей вроде $post->title = 'New Title' срабатывают магические методы __get или __set. В первый раз они хотят понять, какие колонки имеются у привязанной таблицы, и для этого совершается SQL-запрос на получение схемы:

SELECT
    kcu.constraint_name,
    kcu.column_name,
    kcu.referenced_table_name,
    kcu.referenced_column_name
FROM information_schema.referential_constraints AS rc
JOIN information_schema.key_column_usage AS kcu ON
    ...
WHERE rc.constraint_schema = database() AND kcu.table_schema = database()
AND rc.table_name = 'ar_employee' AND kcu.table_name = 'ar_employee'

и далее уже сущность работает с полученным списком колонок.

И так любое использование ActiveRecord-классов в тестах генерирует запросы на получение схемы таблицы в БД. Именно это мешает чистому Unit-тестированию бизнес-логики.

Но давайте попробуем, так ли это у нас сейчас. Мы пока не делали таблицы ar_employees, ar_employee_phones и ar_employee_statuses. Попробуем проверить, запускаются ли тесты сущности без них:

vendor/bin/codecept run unit entities

Как видим:

Unit Tests (15) --------------------------------------------
 ArchiveTest: Success (0.02s)
 ArchiveTest: Already archived (0.00s)
 ChangeAddressTest: Success (0.00s)
 CreateTest: Success (0.00s)
 CreateTest: Without phones (0.00s)
 CreateTest: With same phone numbers (0.00s)
 PhoneTest: Add (0.00s)
 PhoneTest: Add exists (0.00s)
 PhoneTest: Remove (0.00s)
 PhoneTest: Remove not exists (0.00s)
 ReinstateTest: Success (0.00s)
 ReinstateTest: Not archived (0.00s)
 RemoveTest: Success (0.00s)
 RemoveTest: Not archived (0.00s)
 RenameTest: Success (0.00s)
------------------------------------------------------------

Time: 231 ms, Memory: 10.00MB

OK (15 tests, 56 assertions)

Почему наши тесты прошли даже без таблиц?

Причина в том, что весь код сущностей работает только с имеющимися приватными полями. Он нигде не работаем с полями из БД или со связями напрямую, поэтому магические методы __get и __set нигде не вызываются и ActiveRecord не лезет в БД за схемой. К оригинальным данным мы обращаемся только в методах afterFind и beforeSave, которые в тестах не используются. Поэтому мы легко можем тестировать такие ActiveRecord-классы в модульных тестах без подключения к БД.

А теперь напишем репозиторий для управления нашей сущностью.

Написание репозитория

Как мы помним, любой наш репозиторий должен реализовывать такой интерфейс:

interface EmployeeRepository
{
    /**
     * @param Id $id
     * @return Employee
     * @throws NotFoundException
     */
    public function get(Id $id): Employee;
 
    public function add(Employee $employee): void;
 
    public function save(Employee $employee): void;
 
    public function remove(Employee $employee): void;
}

Поэтому напишем такой класс, который внутри себя будет вызывать соответствующие методы из нашего ActiveRecord-объекта $employee:

namespace app\repositories;
 
use app\entities\Employee\Employee;
use app\entities\Employee\Id;
 
class AREmployeeRepository implements EmployeeRepository
{
    public function get(Id $id): Employee
    {
        if (!$employee = Employee::findOne($id->getId())) {
            throw new NotFoundException('Employee not found.');
        }
        return $employee;
    }
 
    public function add(Employee $employee): void
    {
        if (!$employee->insert()) {
            throw new \RuntimeException('Adding error.');
        }
    }
 
    public function save(Employee $employee): void
    {
        if ($employee->update() === false) {
            throw new \RuntimeException('Saving error.');
        }
    }
 
    public function remove(Employee $employee): void
    {
        if (!$employee->delete()) {
            throw new \RuntimeException('Removing error.');
        }
    }
}

Это уже реальное сохранение в базу данных. Для его тестирования напишем миграцию для создания таблиц со всеми полями, которые мы использовали в afterFind и beforeSave:

use yii\db\Migration;
 
class m170326_153134_create_ar_tables extends Migration
{
    $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_general_ci ENGINE=InnoDB';
 
    public function up()
    {
        $this->createTable('{{%ar_employees}}', [
            'employee_id' => $this->char(36)->notNull(),
            'employee_create_date' => $this->dateTime(),
            'employee_name_last' => $this->string(),
            'employee_name_first' => $this->string(),
            'employee_name_middle' => $this->string(),
            'employee_address_country' => $this->string(),
            'employee_address_region' => $this->string(),
            'employee_address_city' => $this->string(),
            'employee_address_street' => $this->string(),
            'employee_address_house' => $this->string(),
            'employee_current_status' => $this->string(16)->notNull(),
        ], $tableOptions);
 
        $this->addPrimaryKey('pk-ar_employees', '{{%ar_employees}}', 'employee_id');
 
        $this->createTable('{{%ar_employee_phones}}', [
            'phone_id' => $this->primaryKey(),
            'phone_employee_id' => $this->char(36)->notNull(),
            'phone_country' => $this->integer()->notNull(),
            'phone_code' => $this->string()->notNull(),
            'phone_number' => $this->string()->notNull(),
        ], $tableOptions);
 
        $this->createIndex('idx-ar_employee_phones-employee_id', '{{%ar_employee_phones}}', 'phone_employee_id');
        $this->addForeignKey('fk-ar_employee_phones-employee', '{{%ar_employee_phones}}', 'phone_employee_id', '{{%ar_employees}}', 'employee_id', 'CASCADE', 'RESTRICT');
 
        $this->createTable('{{%ar_employee_statuses}}', [
            'status_id' => $this->primaryKey(),
            'status_employee_id' => $this->char(36)->notNull(),
            'status_value' => $this->string(32)->notNull(),
            'status_date' => $this->dateTime()->notNull(),
        ], $tableOptions);
 
        $this->createIndex('idx-ar_employee_statuses-employee_id', '{{%ar_employee_statuses}}', 'status_employee_id');
        $this->addForeignKey('fk-ar_employee_statuses-employee', '{{%ar_employee_statuses}}', 'status_employee_id', '{{%ar_employees}}', 'employee_id', 'CASCADE', 'RESTRICT');
    }
 
    public function down()
    {
        $this->dropTable('{{%ar_employee_statuses}}');
        $this->dropTable('{{%ar_employee_phones}}');
        $this->dropTable('{{%ar_employees}}');
    }
}

и применим её:

php tests/bin/yii migrate

Как и раньше, создадим пустые фикстуры для очистки базы и напишем тест-наследник:

nnamespace tests\unit\repositories;
 
 use app\repositories\AREmployeeRepository;
 use app\tests\_fixtures\EmployeeFixture;
 use app\tests\_fixtures\EmployeePhoneFixture;
 use app\tests\_fixtures\EmployeeStatusFixture;
 
 class AREmployeeRepositoryTest extends BaseRepositoryTest
 {
     /**
      * @var \UnitTester
      */
     public $tester;
 
     public function _before()
     {
         $this->tester->haveFixtures([
             'employee' => EmployeeFixture::className(),
             'employee_phone' => EmployeePhoneFixture::className(),
             'employee_status' => EmployeeStatusFixture::className(),
         ]);
 
         $this->repository = new AREmployeeRepository();
     }
 }

Теперь запускаем:

vendor/bin/codecept run unit repositories/DoctrineEmployeeRepositoryTest

И видим что-то такое:

Unit Tests (5) ---------------------------------------------
 SqlEmployeeRepositoryTest: Get (0.06s)
 SqlEmployeeRepositoryTest: Get not found (0.03s)
 SqlEmployeeRepositoryTest: Add (0.03s)
 SqlEmployeeRepositoryTest: Save (0.04s)
 SqlEmployeeRepositoryTest: Remove (0.03s)
------------------------------------------------------------

Time: 346 ms, Memory: 8.00MB

OK (5 tests, 14 assertions)

Мы добились полной работоспособности нашей сущности поверх ActiveRecord. Теперь можно заняться «косметическим ремонтом».

Ленивая загрузка

Сейчас у нас связи relatedPhones и relatedStatuses дёргаются сразу в afterFind:

class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait;
 
    private $phones;
    private $statuses = [];
 
    ...
 
    public function afterFind(): void
    {
        ...
 
        $this->phones = new Phones($this->relatedPhones);
        $this->statuses = $this->relatedStatuses;
 
        parent::afterFind();
    }
}

что требует использования «жадной» загрузки или совершает лишние запросы в БД.

Вместо этого сделаем «ленивую» подгрузку связей, как мы уже делали это раньше в статье про нативный репозиторий. Установим библиотеку для создания прокси-классов:

composer require ocramius/proxy-manager

И вместо строки:

$this->phones = new Phones($this->relatedPhones);

в afterFind будем создавать прокси-объект, который запустит инициализирующую анонимную функцию при первом же вызове любого метода объекта $this->phones:

$factory = \Yii::createObject(LazyLoadingValueHolderFactory::class);
 
$this->phones = $factory->createProxy(
    Phones::class,
    function (&$target, LazyLoadingInterface $proxy) {
        $target = new Phones($this->relatedPhones);
        $proxy->setProxyInitializer(null);
    }
);

А уже в beforeSave будем только при необходимости (если это новый объект из конструктора или если это сработавший прокси-объект) отправлять телефоны на сохранение:

use ProxyManager\Proxy\LazyLoadingInterface;
 
class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait;
 
    ...
 
    public function afterFind(): void
    {
        $this->id = new Id(
            $this->getAttribute('employee_id')
        );
 
        ...
 
        $factory = \Yii::createObject(LazyLoadingValueHolderFactory::class);
 
        $this->phones = $factory->createProxy(
            Phones::class,
            function (&$target, LazyLoadingInterface $proxy) {
                $target = new Phones($this->relatedPhones);
                $proxy->setProxyInitializer(null);
            }
        );
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->setAttribute('employee_id', $this->id->getId());
 
        ...
 
        if (!$this->phones instanceOf LazyLoadingInterface || $this->phones->isProxyInitialized()) {
            $this->relatedPhones = $this->phones->getAll();
        }
 
        return parent::beforeSave($insert);
    }
 
    ...
}

Теперь чтение $this->relatedPhones будет спрятано в анонимной функции, срабатывающей только при необходимости.

Чтобы всюду не повторять вызов:

$factory = \Yii::createObject(LazyLoadingValueHolderFactory::class);

мы можем его вынести в статический метод и этот метод переместить в трейт:

namespace app\repositories;
 
use ProxyManager\Factory\LazyLoadingValueHolderFactory;
 
trait LazyLoadTrait
{
    protected static function getLazyFactory(): LazyLoadingValueHolderFactory
    {
        return \Yii::createObject(LazyLoadingValueHolderFactory::class);
    }
}

Потом подключить к классу сущности и вызывать self::getLazyFactory():

class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait, LazyLoadTrait;
 
    ...
 
    public function afterFind()
    {
        ...
 
        $this->phones = self::getLazyFactory()->createProxy(
            Phones::class,
            function (&$target, LazyLoadingInterface $proxy) {
                $target = new Phones($this->relatedPhones);
                $proxy->setProxyInitializer(null);
            }
        );
 
        parent::afterFind();
    }
 
    ...
}

И, напоследок, сделаем отложенную загрузку для статусов. Сейчас у нас поле $statuses представляет из себя обычный массив:

class Employee extends ActiveRecord implements AggregateRoot
{
    private $statuses = [];
 
    private function getCurrentStatus(): Status
    {
        return end($this->statuses);
    }
 
    public function getStatuses(): array
    {
        return $this->statuses;
    }
 
    ...
 
    public function afterFind(): void
    {
        ...
 
        $this->statuses = $this->relatedStatuses;
 
        parent::afterFind();
    }
}

и мы никак не можем его спроксировать. Но мы можем, как и в предыдущих статьях, заменить его на объект стандартного класса \ArrayObject и спроксировать уже его:

use lhs\Yii2SaveRelationsBehavior\SaveRelationsBehavior;
use ProxyManager\Proxy\LazyLoadingInterface;
 
class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait;
 
    private $id;
    private $name;
    private $address;
    private $phones;
    private $createDate;
    /**
     * @var ArrayObject
     */
    private $statuses;
 
    public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones)
    {
        ...
        $this->statuses = new ArrayObject();
        $this->createDate = $date;
        $this->addStatus(Status::ACTIVE, $date);
        ...
    }
 
    ...
 
    private function getCurrentStatus(): Status
    {
        $statuses = $this->statuses->getArrayCopy();
        return end($statuses);
    }
 
    ...
 
    public function getStatuses(): array { return $this->statuses->getArrayCopy(); }
 
    ...
 
    public function afterFind(): void
    {
        $this->id = new Id(
            $this->getAttribute('employee_id')
        );
 
        ...
 
        $factory = self::getLazyFactory();
 
        $this->phones = $factory->createProxy(
            Phones::class,
            function (&$target, LazyLoadingInterface $proxy) {
                $target = new Phones($this->relatedPhones);
                $proxy->setProxyInitializer(null);
            }
        );
 
        $this->statuses = $factory->createProxy(
            ArrayObject::class,
            function (&$target, LazyLoadingInterface $proxy) {
                $target = new ArrayObject($this->relatedStatuses);
                $proxy->setProxyInitializer(null);
            }
        );
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->setAttribute('employee_id', $this->id->getId());
 
        ...
 
        if (!$this->phones instanceOf LazyLoadingInterface || $this->phones->isProxyInitialized()) {
            $this->relatedPhones = $this->phones->getAll();
        }
 
        if (!$this->statuses instanceOf LazyLoadingInterface || $this->statuses->isProxyInitialized()) {
            $this->relatedStatuses = $this->statuses->getArrayCopy();
        }
 
        return parent::beforeSave($insert);
    }
 
    ...
}

Вот и всё. Так мы улучшим производительность, обеспечив загрузку данных из связанных таблиц только по требованию. Другие методы вроде $employee->changeAddress(...) не затрагивают работу с телефонами и статусами, поэтому они в этом случае загружаться из БД не будут.

А теперь, как и раньше, добавим JSON.

Поддержка JSON

Для экономии ресурсов (чтобы не делать лишних таблиц) в прошлой статье мы сделали сохранение истории статусов в поле statuses самой таблицы employee. Сделаем тоже самое и сейчас.

Переделаем базу под хранение статусов в поле JSON. Добавим поле employee_statuses типа JSON в БД:

use yii\db\Migration;
 
class m170402_074418_add_ar_json_statuses_field extends Migration
{
    public function up()
    {
        $this->addColumn('{{%ar_employees}}', 'employee_statuses', 'JSON');
    }
 
    public function down()
    {
        $this->dropColumn('{{%ar_employees}}', 'employee_statuses');
    }
}

и применим миграцию:

php tests/bin/yii migrate

Мы больше не будем сохранять объекты класса Status в отдельную таблицу. Поэтому вернём этот класс в первоначальный чистый вид без наследования от ActiveRecord и без инфраструктурных методов tableName, afterFind и beforeSave:

namespace app\entities\Employee;
 
use Assert\Assertion;
 
class Status
{
    const ACTIVE = 'active';
    const ARCHIVED = 'archived';
 
    private $value;
    private $date;
 
    public function __construct(string $value, \DateTimeImmutable $date)
    {
        Assertion::inArray($value, [
            self::ACTIVE,
            self::ARCHIVED
        ]);
 
        $this->value = $value;
        $this->date = $date;
    }
 
    public function isActive(): bool
    {
        return $this->value === self::ACTIVE;
    }
 
    public function isArchived(): bool
    {
        return $this->value === self::ARCHIVED;
    }
 
    public function getValue(): string { return $this->value; }
    public function getDate(): \DateTimeImmutable { return $this->date; }
}

Аналогично уберём назад из Employee использование объекта класса \ArrayObject для $this->statuses, оставив его просто массивом. И перепишем методы afterFind и beforeSave на сбор и разбор массива $this->statuses в поле employee_statuses и обратно с использованием Json::encode и Json::decode:

class Employee extends ActiveRecord implements AggregateRoot
{
    use EventTrait, InstantiateTrait, LazyLoadTrait;
 
    ...
 
    private $statuses = [];
 
    ...
 
    private function getCurrentStatus(): Status
    {
        return end($this->statuses);
    }
 
    private function addStatus($value, \DateTimeImmutable $date): void
    {
        $this->statuses[] = new Status($value, $date);
    }
 
    ...
 
    public function getStatuses(): array { return $this->statuses; }
 
    ######## INFRASTRUCTURE #########
 
    ...
 
    public function afterFind(): void
    {
        $this->id = new Id(
            $this->getAttribute('employee_id')
        );
 
        ...
 
        $this->phones = self::getLazyFactory()->createProxy(
            Phones::class,
            function (&$target, LazyLoadingInterface $proxy) {
                $target = new Phones($this->relatedPhones);
                $proxy->setProxyInitializer(null);
            }
        );
 
        $this->statuses = array_map(function ($row) {
            return new Status(
                $row['value'],
                new \DateTimeImmutable($row['date'])
            );
        }, Json::decode($this->getAttribute('employee_statuses')));
 
 
        parent::afterFind();
    }
 
    public function beforeSave($insert): bool
    {
        $this->setAttribute('employee_id', $this->id->getId());
 
        ...
 
        if (!$this->phones instanceOf LazyLoadingInterface || $this->phones->isProxyInitialized()) {
            $this->relatedPhones = $this->phones->getAll();
        }
 
        $this->setAttribute('employee_statuses', Json::encode(array_map(function (Status $status) {
            return [
                'value' => $status->getValue(),
                'date' => $status->getDate()->format(DATE_RFC3339),
            ];
        }, $this->statuses)));
 
        return parent::beforeSave($insert);
    }
 
    public function getRelatedPhones(): ActiveQuery
    {
        return $this->hasMany(Phone::className(), ['phone_employee_id' => 'employee_id'])->orderBy('phone_id');
    }
}

Полный исходный код можно посмотреть на GitHub со всеми изменениями в ветке ar.

Для проверки, не сломали ли мы сущность, можно заново запустить тесты для самой сущности.

А в тестах для репозитория теперь уберём EmployeeStatusFixture и запустим их снова:

Unit Tests (5) ---------------------------------------------
 SqlEmployeeRepositoryTest: Get (0.07s)
 SqlEmployeeRepositoryTest: Get not found (0.02s)
 SqlEmployeeRepositoryTest: Add (0.03s)
 SqlEmployeeRepositoryTest: Save (0.03s)
 SqlEmployeeRepositoryTest: Remove (0.02s)
------------------------------------------------------------

Time: 310 ms, Memory: 8.00MB

OK (5 tests, 14 assertions)

Всё, сохранение работает.

Регистрация в DI-контейнере

Для работы приложения нам нужно привязать наш новый класс AREmployeeRepository к интерфейсу AREmployeeRepository в контейнере внедрения зависимостей:

namespace app\bootstrap;
 
use app\dispatchers\EventDispatcher;
use app\dispatchers\DummyEventDispatcher;
use app\repositories\AREmployeeRepository;
use app\repositories\EmployeeRepository;
use ProxyManager\Factory\LazyLoadingValueHolderFactory;
use yii\base\BootstrapInterface;
 
class ContainerBootstrap implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $container = \Yii::$container;
 
        $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class);
 
        $container->setSingleton(LazyLoadingValueHolderFactory::class);
 
        $container->setSingleton(EmployeeRepository::class, AREmployeeRepository::class);
    }
}

И чтобы новые объекты у нас создавалить только по одному экземпляру мы обьявили их синглтонами.

Итог

В этом примере мы практически отказались от традиционного использования ActiveRecord как простого набора полей. Вместо этого мы работаем с сущностью только через наши методы, а взаимодействие с полями таблицы полностью прячем внутри.

Как разделение на сущность и репозиторий позволяет изменять эти части независимо друг от друга, так и здесь разделение на логику и инфраструктуру (с внедрением преобразующей прослойки между полями сущности и полями из БД) даёт нам полную свободу действий:

Мы можем переименовывать поля в таблицах и спокойно изменять их типы, не меняя ничего кроме преобразований в afterFind beforeSave. Можем хранить в полях любые массивы и объекты. И можем спокойно читать холивары вроде этого, так как в любой момент можем свободно поменять в БД формат с INTEGER на DATETIME, а наш код продолжит как и раньше работать с DateTimeImmutable.

Что выбрать, если хочется сделать проект с богатой доменной моделью?

  • Если хотите полную поддержку ООП «из коробки» со вложенными объектами-значениями без мороки со слежением за изменениями и связями, то возьмите Doctrine ORM. Просто подключите к проекту и настройте. Или (если не хотите настраивать и при этом получить много интересных продвинутых вещей) начните проект на Symfony Framework.

  • Если пишете на другом фреймворке и не хотите тянуть в проект чужие тяжеловесные библиотеки, то попробуйте интегрировать это в ваш ActiveRecord.

  • Если имеете дело с разными базами данных, хотите полностью контролировать процесс или оптимизировать производительность, то напишите свой репозиторий.

Какие можно сделать выводы по рассмотренным в этом цикле статей примерам?

  • При подходе Code First мы получили готовый код всех сущностей за неделю до подключения БД. И вообще так можно написать ядро проекта даже до выбора фреймворка.

  • Мы можем программировать любые структуры объектов и типов без каких-либо ограничений, так как впоследствии можем написать любой преобразователь.

  • Отделение кода логики от БД позволяет изменять структуру хранения в целях оптимизации. Даже в нашем примере с модернизированным AR мы смогли незаметно перейти на JSON-поля.

  • Мы можем переключать или переписывать репозитории без изменения кода приложения и тестов.

  • Умение создавать более-менее платформонезависимый код делает нас более свободными, но требует много самодисциплины.

  • Все методы сущности у нас оказались протестированными, так как тесты мы придумывали до написания кода самих сущностей.

  • Однажды написанные грамотные тесты позволили нам разработать девять вариаций четырёх репозиториев, ни разу не проверяя ничего вручную.

Что разного у всех репозиториев? Внутренности. А что одинаково? Внешность и суть. Если бы мы тестировали внутренности (как именно сохраняются значения в каждое поле БД), то пришлось бы написать четыре огромных тестовых набора с обширными фикстурами для каждого репозитория и каждый раз их переписывать при внедрении JSON или переименовании полей. Но вместо этого мы подготовили один тестовый набор, проверяющий именно суть работы хранилища: помещаем сущность в add, считываем назад через get и проверяем, всё ли совпадает. Такой универсальный тест подойдёт для любого репозитория.

Если тестировать только видимые публичные методы, то не придётся переписывать тесты при каждом изменении внутренностей. Тестируйте суть.

Когда какой архитектурный подход выбрать?

  • Если в проекте пока имеются только листинги строк, допускающие отдельное редактирование любых полей, то используйте стандартный ActiveRecord-подход с управлением через CRUD.

  • Если же появляется замысловатая логика и кучи проверок вроде if-ов или switch-ей, то удобнее постепенно или сразу перейти на нормальное ООП и многие проверки из контроллеров аккуратно переместить в методы самих сущностей.

И на этом наш цикл создания репозиторией пока завершён. Если появятся новые идеи, то продолжим.

Комментарии

 

Foldes Patrik

Спасибо, хорошая статья!

Я использую несколько иной подход.

Преимущества по мимо тех, что имеются в вашем решении:
1) В классе сущности вообще ни строки свзяанной с работой с БД (методы before/afterSave, tableName и т.д.).
2) Класс сущности можно "отлучить" от ActiveRecord просто добавив в него все поля из ActiveRecord в виде приватных свойств и убрав наследование.
3) Меньше кода, позволяет быстрее работать используя RAD-преимущества фреймворка.
4) Класс ActiveRecord обьявляем как абстрактный, чтобы никому не было повадно с ним напрямую работать.

Минусы:
1) Вместо конструктора использую Fluent Interface для сборки обьекта (каюсь, но чертовски удобно).
2) Часть валидации оставляю в AR и репозитории у меня при сохранении кидают исключение if (!$ar->validate() or !$ar->save()). Тоже не совсем красиво, т.к. до момента сохранения может гулять созданный обьект у которого не соблюдены все инварианты - но сильно экономит время, если приложение простое (не нужна проверка инвариантов до сохранения вообще, тупой CRUD).

<?php declare(strict_types=1);
namespace Prohub\Contexts\AuthorizationContext\DataAccess\ActiveRecord;

use Prohub\Contexts\AuthorizationContext\Entities\User;
use alexinator1\jta\ActiveRecord;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\Expression;

abstract class AuthEmailTokenRecord extends ActiveRecord
{
    public static function tableName() :string {
        return '{{%auth_email_token}}';
    }

    public function behaviors() :array {
        return [
            [
                'class' => TimestampBehavior::class,
                'createdAtAttribute' => 'ttl',
                'updatedAtAttribute' => false,
                'value' => new Expression('DATE_ADD(NOW(), INTERVAL '.static::getTokenTTLDays().' DAY)')
            ]
        ];
    }

    public function rules() :array {
        return [
            [['userId', 'token'], 'required'],
            [['userId'], 'integer'],
            [['token'], 'string', 'max' => 255]
        ];
    }

    public function getUserRelation() :ActiveQuery {
        return $this->hasOne(User::class, ['id' => 'userId']);
    }

    abstract public static function getTokenTTLDays() :int;
}
<?php declare(strict_types=1);
namespace Prohub\Contexts\AuthorizationContext\Entities;

use Prohub\Contexts\AuthorizationContext\DataAccess\ActiveRecord\AuthEmailTokenRecord;
use Yii;
use DateTime;

class AuthEmailToken extends AuthEmailTokenRecord
{
    public const TOKEN_TTL_DAYS = 30;

    public function getId() :?int {
        return $this->id;
    }

    public function getToken() :?string {
        return $this->token;
    }

    public function getUser() :?User {
        return $this->userRelation;
    }

    public function setUser(?User $user) :self {
        if ($user) {
            $this->userId = $user->getId();
        }
        return $this;
    }

    public function getTTL() :?DateTime {
        if ($this->ttl) {
            return (new DateTime())->setTimestamp(
                strtotime($this->ttl)
            );
        }
        return null;
    }

    public function generateToken() :self {
        $this->token = Yii::$app->security->generateRandomString(20);
        return $this;
    }

    public static function getTokenTTLDays() :int {
        return self::TOKEN_TTL_DAYS;
    }
}
Ответить

 

Foldes Patrik

Да, еще, конечно как минус - де-факто у нас свойства ActiveRecord остаются доступны напрямую, т.е. можно при желании вместо $entity->setUser($user) сделать $entity->userId = $user->getId(), но это решаемо удалением докблоков в ActiveRecord чтобы IDE не показывала эти свойства и соглашением так не делать внутри команды при командной разработке.

Ответить

 

Александр

Я думаю, что в вашем случае лучше заменить наследование композицией, а не надеяться, что все в команде будут соблюдать соглашения.
Т.е будет код типа:

class AuthEmailToken
{
    private $ar;
    public function __construct( AuthEmailTokenRecord $ar)
    {
        $this->ar = $ar;
    }
    
    public function getUser() :?User {
        return $this->ar->userRelation;
    }

    public function setUser(?User $user) :self {
        if ($user) {
            $this->ar->userId = $user->getId();
        }
        return $this;
    }
    
}
Ответить

 

Владимир

Дмитрий, большое вам человеческое спасибо, за то что простыми словами на пальцах объясняете сложные вещи! Данную статью ожидал с особым нетерпением, т.к. надеялся, что в ней вы немного зацепите момент создания/редактирования сущности из данных пришедших извне. Подскажите, как при использовании такого подхода будет выглядеть к примеру EmployeeController::actionCreate?

Ответить

 

Владимир

Спасибо, за ответ. упустил из виду эту статью

Ответить

 

anton_z

На мой взгляд, самый красивый код получился в реализации с Doctrine. Его там меньше всего и он самый легкий для поддержки.
C AR получилось много головняка с ручным преобразованием типов и проксированием связей для достижения тестируемости.

А если так вот проксировать связи и обращаться к атрибутам исключительно в afterFind и beforeSave, точно не придется к unit тестам базу подключать, на реальных проектах применяли? Этот подход работает в yii1 (нужно для поддержки старого проекта)?

Ответить

 

Виктор

С нетерпением ждал именно эту статью (все статьи Дмитрия про Yii2 до сих пор высшего класса).

Однако по-моему, здесь что-то сильно не так...

В подходе с Doctrine у нас был чистенький простейший класс Employee (и вся работа с
хранилищем была вынесена в репозиторий-объект) - все удобно и по канону, как и надо.

Здесь вся работа с базой по-прежнему делается в AR Employee, перегруженного всякими заглушками, трейтами, AfterFind/BeforeSave, имя таблицы в базе, имена полей индексов в базе к связанных сущностей и т.д...
А в якобы repository объекте EmployeeRepository - на самом деле стоят одни обертки, к вызовов того же AR класса Employee.

Если меняется что-то с базой (напр. новое поле в таблице Employee) - исправлять нужно в в коде того же AR Employee (а не его репозитори-объекта).

Если нужно поменять хранилище, то кроме создания дополнительного репозитори-объекта (который будет работать с новым хранилищем) - в основном объекте Employee останется груда лишнего "инфраструктурного кода" по-прежнему завязанный к хранению в MySQL базой, всякие реляции, связанные таблицы и имена индексов, sql запросы, трансакционность, суррогатные поля и прочее...

Неужели нельзя как-нибудь лучше разделить ответственность (и для Employee пользоваться обычном PHP классом, или в крайнем случае отнаследовать хоть от Model) - и чтобы вся работа со слоя базы реально делалась в соответного репозитори-объекта, пусть через AR или еще как?

Казалось бы, это EmployeeRepository должен инкапсулировать весь функционал хранилища AR объект(ов) в базе, а Employee отвязать от этого... а то как-то все с ног на голову - по факту в Employee имплементирован и надстраивается весь функционал хранилища; а в EmployeeRepository - по-факту одна только заглушка к нем...

(если что извиняюсь за граматические ошибки, родной язык не русский)

Ответить

 

Дмитрий Елисеев

Обычный PHP-класс с полным разделением был в позапрошлой части перед Doctrine.

А здесь на то она и ActiveRecord, что всё у неё внутри. При это вначале и указал.

Ответить

 

Виктор

Большое спасибо за ответ!
Простите, но очень хочется спросить конкретно - представим такоe (в общих чертах) изменение к вашем подходе в данной статье:

а) Ваш Employee наследуем от Model (чтобы пользовать его например, еще для валидации)

б) Ваш AREmployeeRepository наследуем от ActiveRecord implements AggregateRoot

в) Весь "инфраструктурный" код из вашего Employee (тот, который сейчас под тегом #INFRASTRUCTURE#) - перемещаем, добавляя его в AREmployeeRepository (плюс необходимые корекции, чтобы код оставался рабочим - например все обертки типа AREmployeeRepository->add() будут вызывать имплементации собственных статичных методов, а не методов Employee)

г) Все остальное оставляем как и есть

Разве это не будет лучшее в архитектурном плане решение, при более-менее того же объема кода и функциональности (фишки AR Yii2 тоже вроде используются в такой же мере, и пр).

Или, здесь есть какие-то подводные камни связанные с архитектурой и/или избыточности кода которые я упускаю....

Хочется подход, в котором средствами Yii получить как можно более полное разделение ответственностей - на подобие того что было с Doctrine?

Ответить

 

Дмитрий Елисеев

Можно создать класс EmployeeActiveRecord и перегонять данные в репозитории в него:

public function add(Employee $employee)
{
    $ar = new EmployeeAR();
    $ar->id = $employee->getId()->getId();
    $ar->address_country = $employee->getAddress()->getCountry();
    ...
    $ar->insert();
}

и обратно заполнять и проксировать через рефлексию в методе get($id). Будет также монструозно.

А текущий подход позволяет работать и без репозитория на голом ActiveRecord и ActiveQuery.

Ответить

 

Виктор

Нет, моя идея не была вводить еще дополнительный EmployeeAR (кроме Employee и ЕmployeeRepository) - это вроде и на самом деле монструознее; как минимум перекидывать поля туда-сюда придется уже два раза.

Я о том, чтобы сам ЕmployeeRepository был ActiveRecord плюс того чтоб, вся работа с базой реально переехала в нем, типа того:

# simple employee class
class Employee extends Model
{
...
}


class AREmployeeRepository extends ActiveRecord implements EmployeeRepository, AggregateRoot
{
   ...
    ####  Repository Interface Implementation ####

    public function get(EmployeeId $id)
    {
        if (!$employee = self::findOne($id->getId())) {
            throw new NotFoundException('Employee not found.');
        }
        return $employee;
    } 
    public function add(Employee $employee) {....}
    public function save(Employee $employee)  {....}
    public function remove(Employee $employee)  {....}
    public function nextId()  {....}

...
    ### INFRASTRUCTURE #### (Storage interface implementation, use Yii AR perks)
 
    public static function tableName() {...}
    public function behaviors()  {...}
    public function transactions()  {...}
    public function afterFind()  {...}
    public function beforeSave($insert)  {...}
    public function getRelatedPhones()  {...}
    public function getRelatedStatuses()  {...}
    ...
}

Т.е. перегнать всю работу с базой из Employee в AREmployeeRepository (перекидывать поля из одного в другого опять понадобится - но это так и у вас - и вроде не избежать когда структура посложнее, и поля не мапятся один к одному).

Монструозность в целом, вроде выходит та же самая как и в вашем решении - но только она теперь вся загнана в AREmployeeRepository; а Employee все-таки остается простeйшим классом/моделью с бизнес логикой
Если в будущем решим переключить backend/framework для хранилища - например на Doctrine для репозиториев - просто используем другую имплементацию репозитория, типа DoctrineEmployeeRepository (и в Employee не останется лишняя груда Yii AR кода)

А текущий подход позволяет работать и без репозитория на голом ActiveRecord и ActiveQuery
Это понятно что в принципе, все можно свалить в единственном монструозном классе AR Employee - но ведь от этого мы хотим избавиться данным подходом, разделяя слои–ответственности, разве нет? При усложнении/росте проекта саппортить такое будет кошмаром
Однако по сути, у вас вроде так и есть - как я понимаю AREmployeeRepository просто заглушка к Employee (в котором все реализируется) - чтобы тесты работали

Через простого (не-AR) Employee и монструозного AREmployeeRepository - мы по меньшей мере абстрагируем логику хранилища отдельно от бизнес логики в коде (и это кажется вроде все-таки лучше, чем свалить все в единственном классе)...

Буду пробовать...

Еще раз спасибо за блог!

Ответить

 

Дмитрий Елисеев

Запрос self::findOne($id) вам вернёт сам объект AREmployeeRepository.

Ответить

 

Виктор

Да конечно это нужно исправить, вернуть объект Employee с перекинутыми аттрибутами от AREmployeeRepository, типа

if (!$employee = self::buildEmployeeWithPopulatedFields(self::findOne($id->getId())) {

Это не рабочий код, я просто наспех копировал чтобы проиллюстрировать идею...

Ответить

 

Александр

Если использовать ActiveRecord только в Repository и использовать чистые Entity (удалить из Employee всю инфраструктуру и не наследовать от ActiveRecord), то получается такой Repository (тесты проходят) https://gist.github.com/dzentota/99513928239bb7187750dc82240441cf

Ответить

 

Александр

Интересно, как мой предыдущий комментарий набрал МИНУС 3630 лайков :-) ?

Ответить

 

Иван

Простите ради бога. Не мог не поиграться с ошибкой типа состояние гонки, Дмитрию об ошибке уже отписал :)

Ответить

 

Volodymyr Tarasov

Добрый день! Переопределял instantiate и конструктор + файлы в vendore. get запрос из репозитория работает, но на $item->create() вылетает исключение. Сделал метод-фабрику, но ничего не изменилось.
Подскажите, в чем может быть проблема или хотя бы направления поиска.

Ответить

 

Дмитрий Елисеев

Какое именно исключение?

Ответить

 

Ivan

Столкнулся с тем, что в ActiveRecord при использовании транзакций в методе update возвращается затронутое кол-во строк. И если никакой атрибут AR не изменился (например, в форме ничего не изменили и нажали Сохранить), то update возвращает "0"

тогда функция save(Employee $employee) в репозитории возвращает исключение
подправил на

public function save(Employee $employee)
{
    if ($employee->update() === false) {
        throw new \RuntimeException('Saving error.');
    }
}

также в методе add

Ответить

 

Дмитрий Елисеев

Спасибо! Исправил с update. А insert можно не трогать, так как он всегда возвращает bool.

Ответить

 

Web Code

Спасибо. Интересно читать.

Ответить

 

Сергей

В тексте «…сделать хитрый сеттер setPhotos (который будет сохранять изначальный массив телефонов в приватную переменную $_oldRelations) и присваивать телефоны в $this->photos (что в Yii вызовет этот сеттер setPhotos)»
вместо «телефон…» должно быть «фото».

Ответить

 

Илья

Здравствуйте!
Есть форма для создания заявки на закупку продукции,

Сама заявка может иметь разные варианты способа доставки и способа оплаты
В зависимости от выбора пользователя ему динамически подставляются нужные поля.

как можно реализовать с возможностью CRUD такую динамическую форму в Yii2 ?

Писать кучу Сценариев модели и обновлять их Ajax при разных выборах пользователя?
(по моему это очень неудобно с учетом что я сильно упростил форму для объяснения сути, в реальности же форма гораздо больше, и вариантов выбора у пользователя много)

https://preview.ibb.co/iyDYAp/image.png

Ответить

 

Анатолий Белов

Вот я пошел похожим путем как в статье. Но в итоге забросил это дело и переписал через ORM репозиторий. Проблем с контролем связей пока не словил, в методах репозитория ровно то, что требуется. Если есть связь с фото, то маловероятно придеться дергать записи не затаскивая эту связь. Ну например в списке статей некоторые связи не нужны, а в самой статье нужны - так это два разных метода репозитория.
Нужно понять, для чего именно был разработан AR паттерн, какая изначальная цель? Мы его юзаем за счет "не надо заморачиваться с SQL", но это такое себе использование AR. Классы AR со временем начинают страдать ожирением, всякие afterFind, beforeSave в конечном счете создают путаницу - какого хрена у меня в списке записей 1970 год дата создания?

Ответить

 

Дмитрий Елисеев

> Ну например в списке статей некоторые связи не нужны, а в самой статье нужны - так это два разных метода репозитория.

По-хорошему доменные сущности нужны только для процессов на запись. Для списков статей удобнее без них делать нативные SQL-запросы через QueryBuilder или PDO с нужными JOIN-ами и рендерить из простых массивов.

Ответить

 

neochar

Дмитрий, спасибо!
В этой статье (https://elisdn.ru/blog/105/services-and-controllers) не хватает ссылок на текущую страницу и на реализацию для Doctrine, добавьте пожалуйста.

Ответить

 

slo_nik

Добрый день.
Такой вопрос.
Сейчас в сущности Employee присутствует инфраструктура от Yii.
Имеет ли смысл выносить этот код в отдельный класс, а саму сущность наследовать от этого класса?
Например

class Employee extends YiiEmployee  implements AggregateRoot
{
   .....
} 

class YiiEmployee extends ActiveRecord
{
    public static function tableName(){...}

    public function afterFind(){....}

    public function beforeSave(){...}

    // остальные методы, которые используют особенности Yii
}
Ответить

 

Дмитрий Елисеев

Можно оставить и так.

Ответить

 

slo_nik

У меня сейчас всё в сущности, но как будет лучше? Убрать, чтобы не мешалось, при переносе можно будет удалить только наследование или заменить.

Ответить

 

Дмитрий Елисеев

Можно убрать в наследник. Или просто переместить вниз класса, чтобы не мешали вверху.

Ответить

 

Maks

Здравствуйте, немного не понятно, почему вы говорите что доменые сущности только на запись? Ведь мы можем, например фильтровать, джоинить создавать новые поля при выборке, которые в записе будут просто не учавствовать, и на основе, этого, генерить уже коллекции объектов с другими полями и тд. Например ну выбрали мы пользователей с количеством коментов более 10 - это ведь все те же пользователи - вместо передачи данных, как массив, мы реализуем коллекцию, наших доменых сущностей, и уже дальше с ней как то работаем. Например нам нужны эти пользователи чтобы вытащить их файлы, а эти файлы нужно еще сжать, и затем отправить их. И мы тогда просто инициализируем пользователей, у них есть метод доступа к файлам, который прочитает их с БД. А дальше у наших доменых сущностей файлов есть метод "сжать" - и мы его использовали. А так будет много копипастов, нам прийдется писать метод сжатия и для домена - например используется при сохранении, и для массива что мы получили с БД,все прийдется переделывать, вообще свою логику выборку и тд. Мне кажется, что если мы передаем только данные то теряется смысл от ддд. Но еще более кажется что я заблуждаюсь. Так что буду рад ответу)

Ответить

 

Константин

Здравствуйте! Подскажите, я не совсем понимаю обновление связи через hasOne по классу SaveRelationsBehavior.

//UserEntity:

public function getCompany() : ActiveQuery {
return $this->hasOne(Company::class,['user_id' => 'id']);
}

CompanyEntity:

public static function create() {
$model = new static();
$model->name = 'blabla';
}

//UserService:

$user = findOne(1);
$user->company = Company::create();
$user->save();

Вопрос - Должна ли создастся связь и проставиться user_id в Company исходя из существующего $user->id? Я делаю логику по такому принципу, но, связь создается с пустым user_id, он равен 0.

Смысле кода не ищите, писал от балды, важна суть.....

Ответить

 

Telkom University – mmpjj.telkomuniversity.ac.id

Какие основные преимущества использования доменных сущностей в сравнении с другими подходами? regard Telkom University

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>