В большинстве реляционных баз данных, к сожалению, нет поддержки наследования, так что приходится реализовывать это вручную. В этой статье я хочу кратко показать, как реализовать такой подход к наследованию, как «single table inheritance», описанный в книге «Patterns of Enterprise Application Architecture» by Martin Fowler.

В соответствии с этим паттерном, нужно использовать общую таблицу для наследуемых моделей и в этой таблице добавить поле type, которое будет определять класс-наследника этой записи.

В этой статье будет использоваться следующая структура наследования моделей:

Car
|- SportCar
|- HeavyCar

Таблица `car` имеет следующую структуру:

CREATE TABLE `car` (
    `id` int NOT NULL AUTO_INCREMENT,
    `name` varchar(255) NOT NULL,
    `type` varchar(255) DEFAULT NULL,
    PRIMARY KEY (`id`)
);

INSERT INTO `car` (`id`, `name`, `type`) VALUES (1, 'Kamaz', 'heavy'), (2, 'Ferrari', 'sport'), (3, 'BMW', 'city');

Модель Car можно сгенерировать с помощью Gii.

Как это работает


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

namespace app\models;

use yii\db\ActiveQuery;

class CarQuery extends ActiveQuery
{
    public $type;

    public function prepare($builder)
    {
        if ($this->type !== null) {
            $this->andWhere(['type' => $this->type]);
        }
        return parent::prepare($builder);
    }
}

И теперь мы можем создать классы-наследники от Car. В них мы определим константу TYPE которая будет хранить тип автомобиля для записи в поле type модели, и переопределим ActiveRecord-методы init, find и beforeSave, в которых этот тип будет автоматически подставляться в модель и в запрос CarQuery. TYPE не обязательно должен быть строкой (разумнее использовать unsigned int) и даже не обязательно константой, но для простоты сделаем так. Таким будет SportCar:

namespace app\models;

class SportCar extends Car
{
    const TYPE = 'sport';

    public function init()
    {
        $this->type = self::TYPE;
        parent::init();
    }

    public static function find()
    {
        return new CarQuery(get_called_class(), ['type' => self::TYPE]);
    }

    public function beforeSave($insert)
    {
        $this->type = self::TYPE;
        return parent::beforeSave($insert);
    }
}

И таким HeavyCar:

namespace app\models;

class HeavyCar extends Car
{
    const TYPE = 'heavy';

    public function init()
    {
        $this->type = self::TYPE;
        parent::init();
    }

    public static function find()
    {
        return new CarQuery(get_called_class(), ['type' => self::TYPE]);
    }

    public function beforeSave($insert)
    {
        $this->type = self::TYPE;
        return parent::beforeSave($insert);
    }
}

Дублирования кода, можно избежать, вынеся эти методы в класс Car и используя вместо константы protected метод Car::getType, но сейчас я не буду на этом останавливаться для простоты.

Теперь нам нужно переопределить метод Car:instantiate: для автоматического создания модели нужного класса, в зависимости от типа:

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

Знающий о всех наследниках switch case в коде модели-родителя — на самом деле не слишком удачное решение, но, опять же, это сделано только для простоты понимания подхода и от этого несложно избавиться чуть усложнив код.

Теперь для single table inheritance всё готово. Вот простой пример его прозрачного использования в контроллере:

// finding all cars we have
$cars = Car::find()->all();
foreach ($cars as $car) {
    echo "$car->id $car->name " . get_class($car) . "<br />";
}

// finding any sport car
$sportCar = SportCar::find()->limit(1)->one();
echo "$sportCar->id $sportCar->name " . get_class($sportCar) . "<br />";

Этот код выведет следующее:

1 Kamaz app\models\HeavyCar
2 Ferrari app\models\SportCar
3 BMW app\models\Car
2 Ferrari app\models\SportCar

Как можно заметить, модели получают класс в соответствии с указанным у них типом.

Обработка уникальных значений


Если в таблице есть поля, отмеченные в модели как уникальные, для того чтобы UniqueValidator пропускал их у разных классов, можно использовать такую приятную фишку Yii как targetClass:

 public function rules()
    {
        return [
            [['MyUniqueColumnName'], 'unique', 'targetClass' => Car::classname()],
        ];
    }


Это вольный перевод одного из полезных «рецептов» для Yii2, написанных хабравчанином SamDark здесь — https://github.com/samdark/yii2-cookbook, так что если эта статья чем-то вам помогла, отправляйте лучики добра — ему, а если не понравилась, то лучи зла мне.

Комментарии (13)


  1. SilverFire
    12.01.2016 15:07
    +3

    О черезмерно толстых моделях писал nepster09 в своей недавней статье Разработка приложений на Yii2 без опыта — прямой путь в АД, но по моему мнению, проблема сводится к сугубо архитектурной и решается очень аккуратно, хороший пример Tairesh и показал.

    Такая структура моделей может быть особенно полезной, когда разные типы одного объекта требуют разных правил валидации или специфического поведения. Описывание особенностей разных типов одной модели в одном классе быстро превращает его в сложноподдерживаемое месиво. А если еще и добавить сценарии — можно плакать. Проверено :)

    Что касается практики, я бы добавил, что создание объектов базового класса Car не всегда хорошая идея. Я вижу две стратегии:

    1) Car — суперкласс. Есть базовые правила валидации того, что есть у всех, типа

    [['type', 'name'], 'required']
    , но создание машин через этот класс невозможно, обязательно использовать уточненные классы. Это хорошо, когда каждый тип гарантированно имеет что-то свое, чего принципиально не может быть у базового и не пересекается с другими. Например, у спортивной машины, наверняка, не будет сцепного устройства для прицепа :)

    2) Car — общий класс машины. Применим, когда удовлетворяет требованиям для создания большинства записей, но лишь некоторые типы содержат что-то специфической и требуют отдельных правил, которые будут вынесены в отдельный класс.

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


  1. SamDark
    12.01.2016 15:42
    +8

    Стоило упомянуть, что это перевод написанного мной одного из рецептов для Yii 2.0 Cookbook. За перевод спасибо.


    1. berezuev
      12.01.2016 16:06

      Блин, а я думал, что у меня дежавю..))


    1. Tairesh
      12.01.2016 18:04

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


      1. zelenin
        12.01.2016 18:13
        +2

        да еще и core-developer yii


      1. SamDark
        12.01.2016 18:15

        Если ещё переводить будете, имейте ввиду, что степень готовности там разная. Если у этот рецепт готов на 99%, то некоторые ещё совсем черновики.


        1. andrewnester
          12.01.2016 21:42

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


          1. SamDark
            13.01.2016 00:47
            +1

            Разные — это, скорее всего, не Single Table Inheritance, а что-то типа Class Table Inheritance. Просто потому как хранить поля для такого в STI не оптимально.


  1. Zazza
    12.01.2016 20:22

    Из названия статьи показалось, что в yii2, точнее в его AR есть реализация STI. Но нет.
    А за статью спасибо, я реализовывал наследование в моделях более хардко(р)но :)


  1. Xu4
    12.01.2016 21:24
    +2

    Дублирования кода, можно избежать, вынеся эти методы в класс Car и используя вместо константы protected метод Car::getType, но сейчас я не буду на этом останавливаться для простоты.

    Можно и не использовать. Достаточно будет описать эти методы в классе Car таким образом:

    class Car extends ActiveRecord
    {
        const TYPE = 'car';
    
        public function init()
        {
            $this->type = static::TYPE;
            parent::init();
        }
    
        public static function find()
        {
            return new CarQuery(get_called_class(), ['type' => static::TYPE]);
        }
    
        public function beforeSave($insert)
        {
            $this->type = static::TYPE;
            return parent::beforeSave($insert);
        }
    }
    


    Ключевое слово static как раз будет указывать, что речь идёт о том классе, который наследует логику, а не о том классе, в котором эта логика прописана (как происходит при использовании self). Соответственно, если класс HeavyCar расширяет класс Car, то при вызове метода HeavyCar::init() инструкция $this->type = static::TYPE; будет, по сути, исполняться как $this->type = HeavyCar::TYPE;, а при вызове SportCar::init() будет исполняться как $this->type = SportCar::TYPE;. При этом, если в дочернем классе нет константы TYPE, то она будет искаться в родительском классе. Вот пример, как работает static:

    class ParentClass
    {
        const TYPE = 'parent';
    
        public function getType()
        {
            return static::TYPE;
        }
    }
    
    class ChildOne extends ParentClass
    {
        const TYPE = 'child one';
    }
    
    class ChildTwo extends ParentClass 
    {}
    
    $instance = new ParentClass();
    echo $instance->getType() . PHP_EOL; // parent
    
    $instance = new ChildOne();
    echo $instance->getType() . PHP_EOL; // child one
    
    $instance = new ChildTwo();
    echo $instance->getType() . PHP_EOL; // parent
    


    А с самим подходом, когда для работы с одной и той же таблицей используются несколько моделей, я не согласен, потому что появится соблазн ввести в таблице Car поле, хранящее JSON-кодированную (или ещё каким-нибудь способом сериализованную) структуру данных с информацией о прицепах для HeavyCar. В случае с СУБД, которые поддерживают JSON это может быть нормально, но если используется MySQL, как это часто бывает, лучше всё-таки подумать над формализацией данных на уровне БД, а не на уровне кода.


    1. Xu4
      12.01.2016 21:31
      +2

      Я уточню на всякий случай про HeavyCar::init(), который выглядит так, будто я пытаюсь вызывать статический метод init, хотя он при этом не статический. В PHP на письме принято методы класса показывать именно таким образом — вне зависимости от того, статичный метод или нет. Просто так принято писать. В официальной документации, например, так и делается. В самом коде, конечно же, я бы использовал коррекный способ вызова в зависимости от статичности метода.


  1. Enkin
    13.01.2016 14:00

    Буквально вчера читал про универсальные модули комментариев для YII2. Универсальные — в том смысле что один и тот же объект комментария может быть привязан к любому другому объекту (пост в блог, товар в каталоге и т.п.) одинаковым образом. Там одним из ключевых вопросов была уникальность идентификаторов объектов (id поста и id товара могут совпадать). Вот тут что-то похожее буду пробовать освоить в контексте этой задачи. Спасибо.


  1. alpust
    14.01.2016 20:06
    +1

    А если еще, разделить Модель на Объект поведения/состояния и репозиторий, то и вовсе можно будет паттерн называть Domain model pattern / Repository pattern :D