Obter instância do subtipo de um modelo com Eloquent

22

Eu tenho um Animalmodelo, baseado na animaltabela.

Esta tabela contém um typecampo que pode conter valores como gato ou cachorro .

Eu gostaria de poder criar objetos como:

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

No entanto, ser capaz de buscar um animal como este:

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

Mas onde $animalseria uma instância Dogou Catdependendo do typecampo, que eu possa verificar usando instance ofou que funcionará com métodos sugeridos por tipo. O motivo é que 90% do código é compartilhado, mas um pode latir e o outro pode miar.

Sei que posso fazer Dog::find($id), mas não é o que quero: só posso determinar o tipo do objeto depois que ele foi buscado. Eu também poderia buscar o Animal e, em seguida, executar find()no objeto certo, mas isso está fazendo duas chamadas ao banco de dados, o que obviamente não quero.

Tentei procurar uma maneira de instanciar "manualmente" um modelo Eloquent como Dog from Animal, mas não consegui encontrar nenhum método correspondente. Alguma idéia ou método que eu perdi por favor?

ClmentM
fonte
@ B001 ᛦ Obviamente, minha classe Dog ou Cat terá interfaces correspondentes, não vejo como isso ajuda aqui?
ClmentM 12/02
@ClmentM Parece um para muitos relacionamentos polimórficos laravel.com/docs/6.x/…
vivek_23 12/02
@ vivek_23 Não, neste caso, ajuda a filtrar comentários de um determinado tipo, mas você já sabe que deseja comentários no final. Não se aplica aqui.
ClmentM
@ ClmentM Eu acho que sim. O animal pode ser gato ou cachorro. Portanto, quando você recupera o tipo de animal da tabela de animais, ele fornece uma instância de Dog ou Cat, dependendo do que está armazenado no banco de dados. A última linha diz: A relação comentável no modelo Comment retornará uma instância Post ou Video, dependendo do tipo de modelo que possui o comentário.
vivek_23 12/02
@ vivek_23 Mergulhei mais na documentação e tentei, mas o Eloquent é baseado na coluna real com o *_typenome para determinar o modelo do subtipo. No meu caso, eu realmente tenho apenas uma tabela, portanto, embora seja um recurso interessante, não no meu caso.
ClmentM 12/02

Respostas:

7

Você pode usar os relacionamentos polimórficos no Laravel, conforme explicado em Documentos oficiais do Laravel . Aqui está como você pode fazer isso.

Defina os relacionamentos no modelo conforme fornecido

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');
    }
}

Aqui você precisará de duas colunas na animalstabela, a primeira é animable_typee a outra é animable_idpara determinar o tipo de modelo anexado a ela em tempo de execução.

Você pode buscar o modelo Cão ou Gato conforme determinado,

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

Depois disso, você pode verificar a $animclasse do objeto usando instanceof.

Essa abordagem ajudará você na expansão futura se você adicionar outro tipo de animal (por exemplo, raposa ou leão) no aplicativo. Ele funcionará sem alterar sua base de código. Esta é a maneira correta de atingir seus requisitos. No entanto, não existe uma abordagem alternativa para alcançar o polimorfismo e a carga ágil em conjunto sem usar uma relação polimórfica. Se você não usar um relacionamento polimórfico , terá mais de uma chamada no banco de dados. No entanto, se você tiver uma única coluna que diferencie o tipo modal, talvez tenha um esquema estruturado errado. Eu sugiro que você melhore isso se você quiser simplificá-lo também para desenvolvimento futuro.

Reescrever o modelo interno newInstance()e newFromBuilder()não é uma maneira boa / recomendada e você deve refazê-lo assim que receber a atualização da estrutura.

Kiran Maniya
fonte
11
Nos comentários da pergunta, ele disse que ele tem apenas uma tabela e que as características polimórficas não são utilizáveis ​​no caso do OP.
shock_gone_wild 18/02
3
Só estou afirmando como é o cenário. Eu pessoalmente também usaria relacionamentos polimórficos;)
shock_gone_wild 18/02
11
@KiranManiya, obrigado por sua resposta detalhada. Estou interessado em mais informações. Você pode explicar por que (1) o modelo de banco de dados dos questionadores está errado e (2) estender as funções de membro público / protegido não é bom / recomendado?
Christoph Kluge
11
@ChristophKluge, você já sabe. (1) O modelo de banco de dados está errado no contexto dos padrões de projeto de laravel. Se você deseja seguir o padrão de design definido pelo laravel, deve ter o esquema do DB de acordo com ele. (2) É um método interno da estrutura que você sugeriu substituir. Não o farei se enfrentar esse problema. A estrutura do Laravel possui suporte de polimorfismo embutido. Por que não usamos isso em vez de reinventar a roda? Você deu uma boa pista na resposta, mas eu nunca preferi código com desvantagem. Em vez disso, podemos codificar algo que ajuda a simplificar a expansão futura.
Kiran Maniya
2
Mas ... a questão toda não é sobre os padrões do Laravel Design. Novamente, temos um cenário específico (talvez o banco de dados seja criado por um aplicativo externo). Todos concordam que o polimorfismo seria o caminho a seguir se você construir do zero. De fato, sua resposta tecnicamente não responde à pergunta original.
shock_gone_wild
5

Eu acho que você poderia substituir o newInstancemétodo no Animalmodelo e verificar o tipo dos atributos e, em seguida, iniciar o modelo correspondente.

    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;
    }

Você também precisará substituir o newFromBuildermétodo.


    /**
     * 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;
    }
Chris Neal
fonte
Não sei como isso funciona. Animal :: find (1) geraria um erro: "tipo de índice indefinido" se você chamar Animal :: find (1). Ou eu estou esquecendo de alguma coisa?
shock_gone_wild 19/02
@shock_gone_wild Você tem uma coluna chamada typeno banco de dados?
Chris Neal
Sim, eu tenho. Mas a matriz $ attribute está vazia se eu fizer um dd ($ attritubutes). O que faz perfeitamente sentido. Como você usa isso em um exemplo real?
shock_gone_wild 20/02
5

Se você realmente quiser fazer isso, poderá usar a seguinte abordagem dentro do seu modelo 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
fonte
5

Como o OP afirmou em seus comentários: O design do banco de dados já está definido e, portanto, o relacionamento polimórfico do Laravel parece não ser uma opção aqui.

Gosto da resposta de Chris Neal porque tive que fazer algo semelhante recentemente (escrever meu próprio driver de banco de dados para dar suporte ao Eloquent para arquivos dbase / DBF) e adquiri muita experiência com os elementos internos do Eloquent ORM do Laravel.

Eu adicionei meu sabor pessoal a ele para tornar o código mais dinâmico, mantendo um mapeamento explícito por modelo.

Recursos suportados que testei rapidamente:

  • Animal::find(1) funciona como solicitado em sua pergunta
  • Animal::all() funciona bem
  • Animal::where(['type' => 'dog'])->get()retornará AnimalDog-objects como uma coleção
  • Mapeamento dinâmico de objetos por classe eloquente que usa essa característica
  • Fallback para Animal-model no caso de não haver mapeamento configurado (ou um novo mapeamento aparecer no DB)

Desvantagens:

  • Está reescrevendo o modelo interno newInstance()e newFromBuilder()inteiramente (copiar e colar). Isso significa que, se houver alguma atualização da estrutura para essas funções de membro, você precisará adotar o código manualmente.

Espero que ajude e estou pronto para todas as sugestões, perguntas e casos de uso adicionais no seu cenário. Aqui estão os casos de uso e exemplos para isso:

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 {}

E este é um exemplo de como ele pode ser usado e abaixo dos respectivos resultados:

$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);

que resulta do seguinte:

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

E, caso você queira usar o MorphTraitaqui, é claro, o código completo:

<?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);
    }
}

Christoph Kluge
fonte
Apenas por curiosidade. Isso também funciona com Animal :: all () A coleção resultante é uma mistura de 'Dogs' e 'Cats'?
shock_gone_wild 18/02
@shock_gone_wild pergunta muito boa! Eu testei localmente e adicionei à minha resposta. Parece funcionar também :-)
Christoph Kluge
2
modificar a função interna do laravel não é a maneira correta. Todas as alterações serão perdidas assim que atualizarmos o laravel e isso estragará tudo. Estar ciente.
Navin D. Shah
Ei Navin, obrigado por mencionar isso, mas já está claramente indicado como desvantagem na minha resposta. Contra-pergunta: Qual é a maneira correta, então?
Christoph Kluge
2

Eu acho que sei o que você está procurando. Considere esta solução elegante que usa escopos de consulta do Laravel, consulte https://laravel.com/docs/6.x/eloquent#query-scopes para obter informações adicionais:

Crie uma classe pai que possui lógica compartilhada:

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

Crie um filho (ou vários) com um escopo de consulta global e um savingmanipulador de eventos:

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;
        });
    }
}

(o mesmo se aplica a outra classe Cat, basta substituir a constante)

O escopo da consulta global atua como uma modificação de consulta padrão, de modo que a Dogclasse sempre procure registros com type='dog'.

Digamos que temos 3 registros:

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

Agora, a chamada Dog::find(1)resultaria em null, porque o escopo da consulta padrão não encontrará o id:1que é a Cat. Ligar Animal::find(1)e Cat::find(1)trabalhará, embora apenas o último dê a você um objeto Cat real.

O bom dessa configuração é que você pode usar as classes acima para criar relações como:

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

E essa relação automaticamente fornecerá apenas todos os animais com o type='dog'(na forma de Dogclasses). O escopo da consulta é aplicado automaticamente.

Além disso, a chamada Dog::create($properties)definirá automaticamente typecomo 'dog'devido ao savinggancho do evento (consulte https://laravel.com/docs/6.x/eloquent#events ).

Observe que a chamada Animal::create($properties)não tem um padrão, typeentão aqui você precisa configurá-la manualmente (o que é esperado).

Chama
fonte
0

Embora você esteja usando o Laravel, nesse caso, acho que você não deve se atentar aos atalhos do Laravel.

Esse problema que você está tentando resolver é um problema clássico que muitas outras linguagens / estruturas resolvem usando o padrão do método Factory ( https://en.wikipedia.org/wiki/Factory_method_pattern ).

Se você deseja que seu código seja mais fácil de entender e sem truques ocultos, use um padrão conhecido em vez de truques ocultos / mágicos sob o capô.

rubens21
fonte
0

A maneira mais fácil ainda é criar método na classe 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;
}

Modelo de resolução

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

Isso retornará a instância da classe Animal, Dog ou Cat, dependendo do tipo de modelo

Ruben Danielyan
fonte