Получить экземпляр подтипа модели с Eloquent

22

У меня есть Animalмодель, основанная на animalтаблице.

Эта таблица содержит typeполе, которое может содержать такие значения, как cat или dog .

Я хотел бы иметь возможность создавать объекты, такие как:

class Animal extends Model { }
class Dog extends Animal { }
class Cat extends Animal { }

Тем не менее, возможность получить животное, как это:

$animal = Animal::find($id);

Но где $animalбудет экземпляр Dogили в Catзависимости от typeполя, которое я могу проверить с помощью instance ofили которое будет работать с подсказками типа. Причина в том, что 90% кода является общим, но один может лаять, а другой - мяу.

Я знаю, что могу сделать Dog::find($id), но это не то, что я хочу: я могу определить тип объекта только после того, как он был выбран. Я мог бы также получить Animal, и затем запустить find()на нужном объекте, но это делает два вызова базы данных, которые я, очевидно, не хочу.

Я попытался найти способ «вручную» создать экземпляр модели Eloquent, такой как Dog from Animal, но не смог найти ни одного соответствующего метода. Любая идея или метод, который я пропустил, пожалуйста?

ClmentM
источник
@ B001 ᛦ Конечно, у моего класса Dog или Cat будут соответствующие интерфейсы, я не понимаю, как это поможет?
КлиментМ
@ClmentM Похоже, полиморфные отношения один ко многим laravel.com/docs/6.x/…
vivek_23
@ vivek_23 Не совсем, в данном случае это помогает отфильтровать комментарии определенного типа, но вы уже знаете, что вам нужны комментарии в конце. Не применяется здесь.
КлиментМ
@ClmentM Я думаю, что так и есть. Животным может быть как кошка, так и собака. Таким образом, когда вы извлекаете тип животного из таблицы животных, он выдаст вам экземпляр Dog или Cat в зависимости от того, что хранится в базе данных. В последней строке указано, что комментируемое отношение в модели Comment вернет экземпляр Post или Video, в зависимости от того, какой тип модели владеет комментарием.
vivek_23
@ vivek_23 Я углубился в документацию и попробовал, но Eloquent основан на фактическом столбце с *_typeименем для определения модели подтипа. В моем случае у меня действительно есть только одна таблица, так что, хотя это хорошая функция, в моем случае это не так.
КлиментМ

Ответы:

7

Вы можете использовать Полиморфные Отношения в Laravel, как описано в Официальных Документах Laravel . Вот как вы можете это сделать.

Определите отношения в модели как дано

class Animal extends Model{
    public function animable(){
        return $this->morphTo();
    }
}

class Dog extends Model{
    public function animal(){
        return $this->morphOne('App\Animal', 'animable');
    }
}

class Cat extends Model{
    public function animal(){
        return $this->morphOne('App\Animal', 'animable');
    }
}

Здесь вам понадобятся два столбца в animalsтаблице, первый из которых, animable_typeа другой - animable_idдля определения типа модели, присоединенной к нему во время выполнения.

Вы можете получить модель собаки или кошки, как указано,

$animal = Animal::find($id);
$anim = $animal->animable; //this will return either Cat or Dog Model

После этого вы можете проверить $animкласс объекта с помощью instanceof.

Этот подход поможет вам в дальнейшем расширении, если вы добавите в приложение еще один тип животных (например, лиса или лев). Это будет работать без изменения вашей кодовой базы. Это правильный способ достижения ваших требований. Однако нет альтернативного подхода для достижения полиморфизма и совместной загрузки без использования полиморфных отношений. Если вы не используете полиморфные отношения , вы получите более одного вызова базы данных. Однако, если у вас есть один столбец, который различает модальный тип, возможно, у вас неправильная структурированная схема. Я предлагаю вам улучшить это, если вы хотите упростить его и для будущего развития.

Переписать внутреннюю модель newInstance()и newFromBuilder()это не очень хороший / рекомендуемый способ, и вам придется переделать ее, как только вы получите обновление из фреймворка.

Киран Мания
источник
1
В комментариях к вопросу он сказал, что у него есть только одна таблица, и полиморфные особенности не могут быть использованы в случае OP.
shock_gone_wild
3
Я просто констатирую, на что похож данный сценарий. Я лично также использовал бы Полиморфные Отношения;)
shock_gone_wild
1
@KiranManiya спасибо за ваш подробный ответ. Я заинтересован в дополнительной информации. Можете ли вы объяснить, почему (1) модель базы данных спрашивающих является неправильной и (2) расширение открытых / защищенных функций-членов не подходит / не рекомендуется?
Кристоф Клюге
1
@ChristophKluge, вы уже знаете. (1) Модель БД неверна в контексте шаблонов проектирования Laravel. Если вы хотите следовать шаблону проектирования, определенному laravel, вы должны иметь схему БД в соответствии с ним. (2) Это внутренний метод фреймворка, который вы предложили переопределить. Я не буду этого делать, если столкнусь с этой проблемой. Laravel Framework имеет встроенную поддержку полиморфизма, так почему бы нам не использовать это, а заново изобретать колесо? Вы дали хорошую подсказку в ответе, но я никогда не предпочитал код с недостатком, вместо этого мы можем кодировать что-то, что помогает упростить дальнейшее расширение.
Киран Мания
2
Но ... весь вопрос не в шаблонах Laravel Design. Опять же, у нас есть данный сценарий (возможно, база данных создается внешним приложением). Все согласятся, что полиморфизм был бы подходящим вариантом, если бы вы строили с нуля. Фактически, ваш ответ технически не отвечает первоначальному вопросу.
shock_gone_wild
5

Я думаю, что вы можете переопределить newInstanceметод в Animalмодели, проверить тип по атрибутам и затем запустить соответствующую модель.

    public function newInstance($attributes = [], $exists = false)
    {
        // This method just provides a convenient way for us to generate fresh model
        // instances of this current model. It is particularly useful during the
        // hydration of new objects via the Eloquent query builder instances.
        $modelName = ucfirst($attributes['type']);
        $model = new $modelName((array) $attributes);

        $model->exists = $exists;

        $model->setConnection(
            $this->getConnectionName()
        );

        $model->setTable($this->getTable());

        $model->mergeCasts($this->casts);

        return $model;
    }

Вам также нужно переопределить newFromBuilderметод.


    /**
     * Create a new model instance that is existing.
     *
     * @param  array  $attributes
     * @param  string|null  $connection
     * @return static
     */
    public function newFromBuilder($attributes = [], $connection = null)
    {
        $model = $this->newInstance([
            'type' => $attributes['type']
        ], true);

        $model->setRawAttributes((array) $attributes, true);

        $model->setConnection($connection ?: $this->getConnectionName());

        $model->fireModelEvent('retrieved', false);

        return $model;
    }
Крис Нил
источник
Я не знаю, как это работает. Animal :: find (1) выдаст ошибку: «неопределенный тип индекса», если вы вызовете Animal :: find (1). Или я что-то упустил?
shock_gone_wild
@shock_gone_wild У вас есть столбец с именем typeв базе данных?
Крис Нил
Да, у меня есть. Но массив $ attribute будет пуст, если я сделаю dd ($ attritubutes). Который действительно имеет смысл. Как вы используете это в реальном примере?
shock_gone_wild
5

Если вы действительно хотите это сделать, вы можете использовать следующий подход внутри вашей модели Animal.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Animal extends Model
{

    // other code in animal model .... 

    public static function __callStatic($method, $parameters)
    {
        if ($method == 'find') {
            $model = parent::find($parameters[0]);

            if ($model) {
                switch ($model->type) {
                    case 'dog':
                        return new \App\Dog($model->attributes);
                    case 'cat':
                        return new \App\Cat($model->attributes);
                }
                return $model;
            }
        }

        return parent::__callStatic($method, $parameters);
    }
}
shock_gone_wild
источник
5

Как указывалось в комментариях ФП: проект базы данных уже установлен, и поэтому Laravel Полиморфные отношения » похоже, здесь не вариант.

я нравится ответ Криса Нила, потому что мне пришлось недавно сделать нечто подобное (написать свой собственный драйвер базы данных для поддержки Eloquent для файлов dbase / DBF) и получить большой опыт работы с внутренними компонентами Laravel Eloquent ORM.

Я добавил свой личный вкус, чтобы сделать код более динамичным, сохраняя явное отображение для каждой модели.

Поддерживаемые функции, которые я быстро протестировал:

  • Animal::find(1) работает как задано в вашем вопросе
  • Animal::all() работает также
  • Animal::where(['type' => 'dog'])->get() вернусь AnimalDog -объекты как коллекцию
  • Динамическое сопоставление объектов для каждого eloquent-класса, который использует эту черту
  • Откат к Animal-модели в случае, если сопоставление не настроено (или в БД появилось новое сопоставление)

Недостатки:

  • Он переписывает модель полностью newInstance()и внутренне newFromBuilder()(копировать и вставлять). Это означает, что если будет какое-либо обновление инфраструктуры для функций-членов, вам нужно будет принять код вручную.

Я надеюсь, что это поможет, и я готов ответить на любые предложения, вопросы и дополнительные варианты использования в вашем сценарии. Вот примеры использования и примеры для этого:

class Animal extends Model
{
    use MorphTrait; // You'll find the trait in the very end of this answer

    protected $morphKey = 'type'; // This is your column inside the database
    protected $morphMap = [ // This is the value-to-class mapping
        'dog' => AnimalDog::class,
        'cat' => AnimalCat::class,
    ];

}

class AnimalCat extends Animal {}
class AnimalDog extends Animal {}

И это пример того, как его можно использовать, и ниже соответствующие результаты для него:

$cat = Animal::find(1);
$dog = Animal::find(2);
$new = Animal::find(3);
$all = Animal::all();

echo sprintf('ID: %s - Type: %s - Class: %s - Data: %s', $cat->id, $cat->type, get_class($cat), $cat, json_encode($cat->toArray())) . PHP_EOL;
echo sprintf('ID: %s - Type: %s - Class: %s - Data: %s', $dog->id, $dog->type, get_class($dog), $dog, json_encode($dog->toArray())) . PHP_EOL;
echo sprintf('ID: %s - Type: %s - Class: %s - Data: %s', $new->id, $new->type, get_class($new), $new, json_encode($new->toArray())) . PHP_EOL;

dd($all);

что приводит к следующему:

ID: 1 - Type: cat - Class: App\AnimalCat - Data: {"id":1,"type":"cat"}
ID: 2 - Type: dog - Class: App\AnimalDog - Data: {"id":2,"type":"dog"}
ID: 3 - Type: new-animal - Class: App\Animal - Data: {"id":3,"type":"new-animal"}

// Illuminate\Database\Eloquent\Collection {#1418
//  #items: array:2 [
//    0 => App\AnimalCat {#1419
//    1 => App\AnimalDog {#1422
//    2 => App\Animal {#1425

И если вы хотите, чтобы вы использовали, MorphTraitздесь, конечно, полный код для этого:

<?php namespace App;

trait MorphTrait
{

    public function newInstance($attributes = [], $exists = false)
    {
        // This method just provides a convenient way for us to generate fresh model
        // instances of this current model. It is particularly useful during the
        // hydration of new objects via the Eloquent query builder instances.
        if (isset($attributes['force_class_morph'])) {
            $class = $attributes['force_class_morph'];
            $model = new $class((array)$attributes);
        } else {
            $model = new static((array)$attributes);
        }

        $model->exists = $exists;

        $model->setConnection(
            $this->getConnectionName()
        );

        $model->setTable($this->getTable());

        return $model;
    }

    /**
     * Create a new model instance that is existing.
     *
     * @param array $attributes
     * @param string|null $connection
     * @return static
     */
    public function newFromBuilder($attributes = [], $connection = null)
    {
        $newInstance = [];
        if ($this->isValidMorphConfiguration($attributes)) {
            $newInstance = [
                'force_class_morph' => $this->morphMap[$attributes->{$this->morphKey}],
            ];
        }

        $model = $this->newInstance($newInstance, true);

        $model->setRawAttributes((array)$attributes, true);

        $model->setConnection($connection ?: $this->getConnectionName());

        $model->fireModelEvent('retrieved', false);

        return $model;
    }

    private function isValidMorphConfiguration($attributes): bool
    {
        if (!isset($this->morphKey) || empty($this->morphMap)) {
            return false;
        }

        if (!array_key_exists($this->morphKey, (array)$attributes)) {
            return false;
        }

        return array_key_exists($attributes->{$this->morphKey}, $this->morphMap);
    }
}
Кристоф Клюге
источник
Просто из любопытства. Работает ли это также с Animal :: all () Является ли итоговая коллекция смесью «Dogs» и «Cats»?
shock_gone_wild
@shock_gone_wild довольно хороший вопрос! Я проверил это локально и добавил к своему ответу. Кажется, работает и :-)
Кристоф Клюге
2
модификация встроенной функции laravel не является правильным способом. Все изменения потеряют, как только мы обновим laravel, и это все испортит. Быть в курсе
Навин Д. Шах
Привет, Навин, спасибо, что упомянул об этом, но это уже ясно указано как недостаток в моем ответе. Встречный вопрос: каков правильный путь?
Кристоф Клюге
2

Я думаю, я знаю, что вы ищете. Рассмотрим это элегантное решение, которое использует области запросов Laravel, см. Https://laravel.com/docs/6.x/eloquent#query-scopes для получения дополнительной информации:

Создайте родительский класс, который содержит разделяемую логику:

class Animal extends \Illuminate\Database\Eloquent\Model
{
    const TYPE_DOG = 'dog';
    const TYPE_CAT = 'cat';
}

Создайте дочерний (или множественный) с глобальной областью запроса и savingобработчиком событий:

class Dog extends Animal
{
    public static function boot()
    {
        parent::boot();

        static::addGlobalScope('type', function(\Illuminate\Database\Eloquent\Builder $builder) {
            $builder->where('type', self::TYPE_DOG);
        });

        // Add a listener for when saving models of this type, so that the `type`
        // is always set correctly.
        static::saving(function(Dog $model) {
            $model->type = self::TYPE_DOG;
        });
    }
}

(то же самое относится и к другому классу Cat, просто замените константу)

Глобальная область запроса действует как модификация запроса по умолчанию, так что Dogкласс всегда будет искать записи с type='dog'.

Скажем, у нас есть 3 записи:

- id:1 => Cat
- id:2 => Dog
- id:3 => Mouse

Теперь вызов Dog::find(1)приведет к тому null, что область запроса по умолчанию не найдет тот, id:1который является Cat. Вызов Animal::find(1)и Cat::find(1)оба будут работать, хотя только последний из них даст вам реальный объект Cat.

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

class Owner
{
    public function dogs()
    {
        return $this->hasMany(Dog::class);
    }
}

И это отношение автоматически только даст вам всех животных с type='dog'(в формеDog классов). Область запроса применяется автоматически.

Кроме того, вызов Dog::create($properties)автоматически установит typeзначение 'dog'из-за savingперехвата события (см. Https://laravel.com/docs/6.x/eloquent#events ).

Обратите внимание, что для вызова Animal::create($properties)не typeзадано значение по умолчанию, поэтому здесь вам нужно установить его вручную (что и следовало ожидать).

пламя
источник
0

Хотя вы используете Laravel, в этом случае, я думаю, вы не должны придерживаться ярлыков Laravel.

Эта проблема, которую вы пытаетесь решить, является классической проблемой, которую многие другие языки / фреймворки решают с помощью шаблона метода Factory ( https://en.wikipedia.org/wiki/Factory_method_pattern ).

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

rubens21
источник
0

Самый простой способ - создать метод в классе Animal.

public function resolve()
{
    $model = $this;
    if ($this->type == 'dog'){
        $model = new Dog();
    }else if ($this->type == 'cat'){
        $model = new Cat();
    }
    $model->setRawAttributes($this->getAttributes(), true);
    return $model;
}

Разрешающая модель

$animal = Animal::first()->resolve();

Это вернет экземпляр класса Animal, Dog или Cat в зависимости от типа модели.

Рубен Даниелян
источник