Ottieni l'istanza del sottotipo di un modello con Eloquent


22

Ho un Animalmodello, basato sul animaltavolo.

Questa tabella contiene un typecampo che può contenere valori come cat o dog .

Vorrei essere in grado di creare oggetti come:

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

Tuttavia, essere in grado di recuperare un animale come questo:

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

Ma dove $animalsarebbe un'istanza Dogo in Catbase al typecampo, che posso controllare usando instance ofo che funzionerà con i metodi con suggerimenti di tipo. Il motivo è che il 90% del codice è condiviso, ma uno può abbaiare e l'altro può miagolare.

So che posso fare Dog::find($id), ma non è quello che voglio: posso determinare il tipo di oggetto solo una volta recuperato. Potrei anche andare a prendere l'Animale e poi correrefind() l'oggetto giusto, ma questo sta facendo due chiamate al database, che ovviamente non voglio.

Ho provato a cercare un modo per "istanziare" manualmente un modello Eloquent come Dog from Animal, ma non sono riuscito a trovare alcun metodo corrispondente. Qualche idea o metodo che ho perso per favore?


@ B001 ᛦ Certo, la mia classe Dog o Cat avrà interfacce corrispondenti, non vedo come aiuta qui?
ClmentM,

@ClmentM Sembra una o più relazioni polimorfiche laravel.com/docs/6.x/…
vivek_23

@ vivek_23 Non proprio, in questo caso aiuta a filtrare i commenti di un determinato tipo, ma sai già che vuoi dei commenti alla fine. Non si applica qui.
ClmentM,

@ClmentM Penso di si. L'animale può essere un gatto o un cane. Quindi, quando recuperi il tipo di animale dalla tabella degli animali, ti darebbe un'istanza di Dog o Cat a seconda di ciò che è memorizzato nel database. L'ultima riga indica La relazione commentabile sul modello Commento restituirà un'istanza Post o Video, a seconda del tipo di modello proprietario del commento.
vivek_23,

@ vivek_23 Ho approfondito la documentazione e l'ho provato, ma Eloquent si basa sulla colonna effettiva con il *_typenome per determinare il modello di sottotipo. Nel mio caso ho davvero solo un tavolo, quindi mentre è una bella caratteristica, non nel mio caso.
ClmentM,

Risposte:


7

Puoi utilizzare le relazioni polimorfiche in Laravel come spiegato in Documenti ufficiali di Laravel . Ecco come puoi farlo.

Definire le relazioni nel modello come indicato

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

Qui avrai bisogno di due colonne nella animalstabella, prima è animable_typee un'altra è animable_idper determinare il tipo di modello ad esso collegato in fase di esecuzione.

Puoi recuperare il modello Dog o Cat come indicato,

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

Successivamente, è possibile controllare la $animclasse dell'oggetto utilizzando instanceof.

Questo approccio ti aiuterà per future espansioni se aggiungi un altro tipo di animale (ad esempio volpe o leone) all'applicazione. Funzionerà senza cambiare la base di codice. Questo è il modo corretto per soddisfare le tue esigenze. Tuttavia, non esiste un approccio alternativo per raggiungere il polimorfismo e il carico desideroso insieme senza usare una relazione polimorfica. Se non usi una relazione polimorfica , finirai con più di una chiamata al database. Tuttavia, se hai una singola colonna che differenzia il tipo modale, forse hai uno schema strutturato errato. Ti suggerisco di migliorarlo se vuoi semplificarlo anche per lo sviluppo futuro.

Riscrivere l'interno del modello newInstance()e newFromBuilder()non è un metodo valido / consigliato e devi rielaborarlo una volta ottenuto l'aggiornamento dal framework.


1
Nei commenti alla domanda ha affermato che ha solo una tabella e che le caratteristiche polimorfiche non sono utilizzabili nel caso di OP.
shock_gone_wild il

3
Sto solo affermando, com'è lo scenario dato. Personalmente
userei

1
@KiranManiya grazie per la risposta dettagliata. Sono interessato a più background. Puoi spiegare perché (1) il modello di database dei questionari è sbagliato e (2) l'estensione delle funzioni dei membri pubblici / protetti non è buona / consigliata?
Christoph Kluge,

1
@ChristophKluge, lo sai già. (1) Il modello DB è errato nel contesto dei modelli di progettazione laravel. Se si desidera seguire il modello di progettazione definito da laravel, è necessario disporre dello schema DB in base a esso. (2) È un metodo interno del framework che è stato suggerito di ignorare. Non lo farò se dovessi mai affrontare questo problema. Il framework Laravel ha il supporto per il polimorfismo incorporato, quindi perché non usarlo piuttosto per reinventare la ruota? Hai dato un buon indizio nella risposta, ma non ho mai preferito il codice con svantaggio, invece possiamo codificare qualcosa che aiuti a semplificare l'espansione futura.
Kiran Maniya,

2
Ma ... l'intera domanda non riguarda i modelli di Laravel Design. Ancora una volta, abbiamo un determinato scenario (forse il database è creato da un'applicazione esterna). Tutti saranno d'accordo sul fatto che il polimorfismo sarebbe la strada da percorrere se si costruisce da zero. Infatti, la tua risposta tecnicamente non risponde alla domanda originale.
shock_gone_wild

5

Penso che potresti sovrascrivere il newInstancemetodo sul Animalmodello, controllare il tipo dagli attributi e quindi avviare il modello corrispondente.

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

Dovrai anche sostituire il newFromBuildermetodo.


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

Non so come funzioni. Animal :: find (1) genererebbe un errore: "tipo di indice non definito" se si chiama Animal :: find (1). Oppure mi sfugge qualcosa?
shock_gone_wild

@shock_gone_wild Hai una colonna nominata typenel database?
Chris Neal,

Sì. Ma l'array $ attributi è vuoto se faccio un dd ($ attritubutes). Il che ha perfettamente senso. Come lo usi in un esempio reale?
shock_gone_wild

5

Se vuoi davvero farlo, puoi usare il seguente approccio all'interno del tuo modello Animale.

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

5

Come ha affermato l'OP nei suoi commenti: la progettazione del database è già impostata e quindi le relazioni polimorfiche di Laravel non sembrano essere un'opzione qui.

Mi piace la risposta di Chris Neal perché di recente ho dovuto fare qualcosa di simile (scrivendo il mio Database Driver per supportare Eloquent per i file dbase / DBF) e ho acquisito molta esperienza con gli interni di Eloquent ORM di Laravel.

Ho aggiunto il mio sapore personale ad esso per rendere il codice più dinamico mantenendo una mappatura esplicita per modello.

Funzionalità supportate che ho testato rapidamente:

  • Animal::find(1) funziona come richiesto nella tua domanda
  • Animal::all() funziona pure
  • Animal::where(['type' => 'dog'])->get()restituirà AnimalDog-object come una raccolta
  • Mappatura dinamica degli oggetti per classe eloquente che utilizza questo tratto
  • Fallback a Animal-model nel caso in cui non sia configurato alcun mapping (o è apparso un nuovo mapping nel DB)

svantaggi:

  • Sta riscrivendo interamente newInstance()e newFromBuilder()interamente il modello (copia e incolla). Ciò significa che se ci saranno aggiornamenti dal framework alle funzioni di questo membro, dovrai adottare il codice manualmente.

Spero che sia di aiuto e sono pronto per eventuali suggerimenti, domande e casi d'uso aggiuntivi nel tuo scenario. Ecco i casi d'uso ed esempi per questo:

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 questo è un esempio di come può essere utilizzato e sotto i rispettivi risultati per esso:

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

che risulta come segue:

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 nel caso tu voglia usare MorphTraitquesto è ovviamente il codice completo per questo:

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

Solo per curiosità. Funziona anche con Animal :: all () La raccolta risultante è una miscela di "Cani" e "Gatti"?
shock_gone_wild

@shock_gone_wild bella domanda! L'ho provato localmente e l'ho aggiunto alla mia risposta. Sembra funzionare anche :-)
Christoph Kluge,

2
la modifica della funzione integrata di laravel non è corretta. Tutte le modifiche andranno perse una volta aggiornato il laravel e rovinerà tutto. Sii consapevole.
Navin D. Shah,

Ehi Navin, grazie per averlo menzionato, ma è già chiaramente indicato come uno svantaggio nella mia risposta. Contro-domanda: qual è il modo corretto allora?
Christoph Kluge,

2

Penso di sapere cosa stai cercando. Considera questa elegante soluzione che utilizza gli ambiti di query Laravel, vedi https://laravel.com/docs/6.x/eloquent#query-scopes per ulteriori informazioni:

Creare una classe genitore che contiene la logica condivisa:

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

Crea un figlio (o più) con un ambito di query globale e un savinggestore eventi:

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 stesso vale per un'altra classe Cat, basta sostituire la costante)

L'ambito della query globale funge da modifica della query predefinita, in modo che la Dogclasse cercherà sempre i record type='dog'.

Supponiamo di avere 3 record:

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

Ora la chiamata Dog::find(1)comporterebbe null, poiché l'ambito della query predefinito non troverà id:1quale è a Cat. Chiamare Animal::find(1)e Cat::find(1)funzionerà entrambi, anche se solo l'ultimo ti dà un vero oggetto Cat.

La cosa bella di questa configurazione è che puoi usare le classi sopra per creare relazioni come:

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

E questa relazione ti darà automaticamente solo tutti gli animali con type='dog'(sotto forma di Dogclassi). L'ambito della query viene applicato automaticamente.

Inoltre, la chiamata Dog::create($properties)imposterà automaticamente su typea 'dog'causa savingdell'hook dell'evento (vedere https://laravel.com/docs/6.x/eloquent#events ).

Si noti che la chiamata Animal::create($properties)non ha un valore predefinito, typequindi qui è necessario impostarlo manualmente (cosa che ci si aspetta).


0

Anche se stai usando Laravel, in questo caso, penso che non dovresti attenerti alle scorciatoie di Laravel.

Questo problema che si sta tentando di risolvere è un classico problema che molte altre lingue / framework risolvono utilizzando il modello del metodo Factory ( https://en.wikipedia.org/wiki/Factory_method_pattern ).

Se vuoi che il tuo codice sia più facile da capire e senza trucchi nascosti, dovresti usare un modello ben noto invece di trucchi nascosti / magici sotto il cofano.


0

Il modo più semplice ancora è fare il metodo nella 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;
}

Modello di risoluzione

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

Ciò restituirà un'istanza di classe Animale, Cane o Gatto a seconda del tipo di modello

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.