Obtener instancia del subtipo de un modelo con Eloquent

22

Tengo un Animalmodelo, basado en la animaltabla.

Esta tabla contiene un typecampo que puede contener valores como cat o dog .

Me gustaría poder crear objetos como:

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

Sin embargo, poder buscar un animal como este:

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

Pero donde $animalsería una instancia de Dogo Catdependiendo del typecampo, que puedo verificar usando instance ofo que funcionará con métodos de tipo insinuado. La razón es que el 90% del código se comparte, pero uno puede ladrar y el otro puede maullar.

Sé que puedo hacer Dog::find($id), pero no es lo que quiero: puedo determinar el tipo de objeto solo una vez que fue recuperado. También podría buscar el Animal y luego ejecutar find()el objeto correcto, pero esto está haciendo dos llamadas a la base de datos, que obviamente no quiero.

Traté de buscar una forma de instanciar "manualmente" un modelo Eloquent como Dog from Animal, pero no pude encontrar ningún método correspondiente. ¿Alguna idea o método que me perdí por favor?

ClmentM
fuente
@ B001 ᛦ Por supuesto, mi clase de perro o gato tendrá las interfaces correspondientes, ¿no veo cómo ayuda aquí?
ClmentM
@ClmentM Parece una relación polimórfica de uno a muchos laravel.com/docs/6.x/…
vivek_23
@ vivek_23 En realidad no, en este caso ayuda a filtrar comentarios de un tipo dado, pero ya sabes que quieres comentarios al final. No aplica aquí.
ClmentM
@ClmentM, creo que sí. Los animales pueden ser gatos o perros. Entonces, cuando recupera el tipo de animal de la tabla de animales, le daría una instancia de Perro o Gato dependiendo de lo que esté almacenado en la base de datos. La última línea allí dice: La relación comentable en el modelo de comentario devolverá una instancia de publicación o video, dependiendo del tipo de modelo que posea el comentario.
vivek_23
@ vivek_23 Me sumergí más en la documentación y la probé, pero Eloquent se basa en una columna real con *_typenombre para determinar el modelo de subtipo. En mi caso, realmente solo tengo una tabla, así que si bien es una buena característica, no en mi caso.
ClmentM

Respuestas:

7

Puede usar las Relaciones polimórficas en Laravel como se explica en Documentos oficiales de Laravel . Así es como puedes hacer eso.

Definir las relaciones en el modelo como se da

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

Aquí necesitará dos columnas en la animalstabla, la primera es animable_typey otra es animable_iddeterminar el tipo de modelo adjunto en tiempo de ejecución.

Puede buscar el modelo de perro o gato como se indica,

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

Después de eso, puede verificar la $animclase del objeto utilizando instanceof.

Este enfoque lo ayudará para una futura expansión si agrega otro tipo de animal (es decir, zorro o león) en la aplicación. Funcionará sin cambiar su base de código. Esta es la forma correcta de cumplir con sus requisitos. Sin embargo, no existe un enfoque alternativo para lograr el polimorfismo y la carga ansiosa juntos sin utilizar una relación polimórfica. Si no usa una relación polimórfica , terminará con más de una llamada a la base de datos. Sin embargo, si tiene una sola columna que diferencia el tipo modal, quizás tenga un esquema estructurado incorrecto. Le sugiero que mejore eso si desea simplificarlo también para el desarrollo futuro.

Reescribir el modelo interno newInstance()y newFromBuilder()no es una forma buena / recomendada, y debe volver a trabajar en él una vez que obtenga la actualización del marco.

Kiran Maniya
fuente
1
En los comentarios de la pregunta, dijo, que solo tiene una tabla y que las características polimórficas no se pueden usar en el caso de OP.
shock_gone_wild
3
Solo estoy diciendo cómo es el escenario dado. Yo personalmente también usaría las relaciones polimórficas;)
shock_gone_wild
1
@ KiranManiya gracias por su respuesta detallada. Estoy interesado en más antecedentes. ¿Puede explicar por qué (1) el modelo de la base de datos de los encuestados es incorrecto y (2) extender las funciones de miembros públicos / protegidos no es bueno / recomendado?
Christoph Kluge
1
@ ChristophKluge, ya lo sabes. (1) El modelo de base de datos es incorrecto en el contexto de los patrones de diseño laravel. Si desea seguir el patrón de diseño definido por laravel, debe tener un esquema de base de datos de acuerdo con él. (2) Es un método interno de marco que sugirió anular. No lo haré si alguna vez enfrento este problema. Laravel Framework tiene soporte de polimorfismo incorporado, entonces, ¿por qué no usamos eso para reinventar la rueda? Dio una buena pista en la respuesta, pero nunca preferí el código con desventaja, sino que podemos codificar algo que ayude a simplificar la expansión futura.
Kiran Maniya
2
Pero ... toda la pregunta no es sobre los patrones de diseño de Laravel. Nuevamente, tenemos un escenario dado (tal vez la base de datos es creada por una aplicación externa). Todos estarán de acuerdo en que el polimorfismo sería el camino a seguir si construyes desde cero. De hecho, su respuesta técnicamente no responde la pregunta original.
shock_gone_wild
5

Creo que podría anular el newInstancemétodo en el Animalmodelo y verificar el tipo de los atributos y luego iniciar el modelo correspondiente.

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

También deberá anular el 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
fuente
No entiendo cómo funciona esto. Animal :: find (1) arrojaría un error: "tipo de índice indefinido" si llama a Animal :: find (1). ¿O me estoy perdiendo algo?
shock_gone_wild
@shock_gone_wild ¿Tiene una columna con nombre typeen la base de datos?
Chris Neal
Sí tengo. Pero el conjunto de atributos $ está vacío si hago un dd ($ attritubutes). Lo cual tiene mucho sentido. ¿Cómo se usa esto en un ejemplo real?
shock_gone_wild
5

Si realmente quieres hacer esto, puedes usar el siguiente enfoque dentro de tu 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
fuente
5

Como el OP declaró en sus comentarios: El diseño de la base de datos ya está configurado y, por lo tanto, las Relaciones Polimórficas de Laravel no parecen ser una opción aquí.

Me gusta la respuesta de Chris Neal porque recientemente tuve que hacer algo similar (escribir mi propio controlador de base de datos para admitir Eloquent para archivos dbase / DBF) y obtuve mucha experiencia con los componentes internos de Eloquent ORM de Laravel.

Le agregué mi sabor personal para hacer que el código sea más dinámico y al mismo tiempo mantener una asignación explícita por modelo.

Funciones compatibles que probé rápidamente:

  • Animal::find(1) funciona como se le preguntó en su pregunta
  • Animal::all() funciona bien
  • Animal::where(['type' => 'dog'])->get()devolverá AnimalDog-objetos como una colección
  • Mapeo dinámico de objetos por clase elocuente que usa este rasgo
  • Vuelva a Animalutilizar el modelo en caso de que no haya una asignación configurada (o si aparece una nueva asignación en la base de datos)

Desventajas

  • Está reescribiendo el modelo interno newInstance()y newFromBuilder()completamente (copiar y pegar). Esto significa que si habrá alguna actualización del marco para las funciones de este miembro, deberá adoptar el código a mano.

Espero que ayude y estoy preparado para cualquier sugerencia, pregunta y casos de uso adicionales en su escenario. Aquí están los casos de uso y ejemplos para ello:

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

Y este es un ejemplo de cómo se puede usar y debajo de los resultados respectivos:

$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 lo siguiente:

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

Y en caso de que quiera usar, MorphTraitaquí está, por supuesto, el 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
fuente
Solo por curiosidad. ¿Esto también funciona con Animal :: all () ¿La colección resultante es una mezcla de 'Perros' y 'Gatos'?
shock_gone_wild
@shock_gone_wild muy buena pregunta! Lo probé localmente y lo agregué a mi respuesta. Parece funcionar también :-)
Christoph Kluge
2
modificar la función incorporada de laravel no es la forma correcta. Todos los cambios se perderán una vez que actualicemos el laravel y lo estropeará todo. Ten cuidado
Navin D. Shah
Hola Navin, gracias por mencionar esto, pero ya está claramente establecido como una desventaja en mi respuesta. Contra pregunta: ¿Cuál es la forma correcta entonces?
Christoph Kluge
2

Creo que sé lo que estás buscando. Considere esta elegante solución que utiliza los ámbitos de consulta de Laravel; consulte https://laravel.com/docs/6.x/eloquent#query-scopes para obtener información adicional:

Cree una clase principal que contenga lógica compartida:

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

Cree un elemento secundario (o múltiple) con un alcance de consulta global y un savingcontrolador 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;
        });
    }
}

(lo mismo se aplica a otra clase Cat, solo reemplace la constante)

El alcance de la consulta global actúa como una modificación de consulta predeterminada, de modo que la Dogclase siempre buscará registros con type='dog'.

Digamos que tenemos 3 registros:

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

Ahora Dog::find(1)se produciría una llamada null, porque el alcance de la consulta predeterminada no encontrará id:1cuál es unCat . Llamar Animal::find(1)y Cat::find(1)funcionará, aunque solo el último le proporciona un objeto Cat real.

Lo bueno de esta configuración es que puede usar las clases anteriores para crear relaciones como:

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

Y esta relación automáticamente solo le dará todos los animales con el type='dog'(en forma de Dogclases). El alcance de la consulta se aplica automáticamente.

Además, llamando Dog::create($properties)establecerá automáticamente el typeque 'dog'debido al savingenlace de eventos (ver https://laravel.com/docs/6.x/eloquent#events ).

Tenga en cuenta que las llamadas Animal::create($properties)no tienen un valor predeterminado, typepor lo que aquí debe configurarlo manualmente (lo cual es de esperar).

Fuego
fuente
0

Aunque está utilizando Laravel, en este caso, creo que no debe apegarse a los atajos de Laravel.

Este problema que está tratando de resolver es un problema clásico que muchos otros lenguajes / marcos resuelven usando el patrón del método Factory ( https://en.wikipedia.org/wiki/Factory_method_pattern ).

Si desea que su código sea más fácil de entender y sin trucos ocultos, debe usar un patrón bien conocido en lugar de trucos ocultos / mágicos debajo del capó.

rubens21
fuente
0

La forma más fácil hasta ahora es hacer un método en la clase 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;
}

Resolviendo modelo

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

Esto devolverá la instancia de la clase Animal, Perro o Gato dependiendo del tipo de modelo

Ruben Danielyan
fuente