Ruft mit Eloquent die Instanz des Subtyps eines Modells ab

22

Ich habe ein AnimalModell, basierend auf der animalTabelle.

Diese Tabelle enthält ein typeFeld, das Werte wie Katze oder Hund enthalten kann .

Ich möchte Objekte erstellen können wie:

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

Ein Tier wie dieses holen zu können:

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

Aber wo $animalwäre eine Instanz von Dogoder Catabhängig von dem typeFeld, das ich mit überprüfen kann instance ofoder das mit typgeführten Methoden funktioniert. Der Grund ist, dass 90% des Codes geteilt werden, aber einer bellen kann und der andere miauen kann.

Ich weiß, dass ich das kann Dog::find($id), aber es ist nicht das, was ich will: Ich kann den Typ des Objekts erst bestimmen, wenn es abgerufen wurde. Ich könnte auch das Tier holen und dann find()auf dem richtigen Objekt ausführen , aber dies führt zwei Datenbankaufrufe aus, die ich offensichtlich nicht möchte.

Ich habe versucht, nach einer Möglichkeit zu suchen, ein eloquentes Modell wie Dog from Animal "manuell" zu instanziieren, konnte jedoch keine entsprechende Methode finden. Irgendeine Idee oder Methode, die ich bitte verpasst habe?

ClmentM
quelle
@ B001 ᛦ Natürlich wird meine Hunde- oder Katzenklasse entsprechende Schnittstellen haben, ich sehe nicht, wie es hier hilft?
ClmentM
@ClmentM Sieht aus wie eine zu viele polymorphe Beziehung laravel.com/docs/6.x/…
vivek_23
@ vivek_23 Nicht wirklich, in diesem Fall hilft es, Kommentare eines bestimmten Typs zu filtern, aber Sie wissen bereits, dass Sie am Ende Kommentare wünschen. Gilt hier nicht.
ClmentM
@ClmentM Ich denke schon. Tier kann entweder Katze oder Hund sein. Wenn Sie also den Tiertyp aus der Tiertabelle abrufen, erhalten Sie eine Instanz von Hund oder Katze, je nachdem, was in der Datenbank gespeichert ist. In der letzten Zeile steht, dass die kommentierbare Beziehung im Kommentar-Modell entweder eine Post- oder eine Video-Instanz zurückgibt, je nachdem, welcher Modelltyp den Kommentar besitzt.
vivek_23
@ vivek_23 Ich habe mich eingehender mit der Dokumentation befasst und sie ausprobiert, aber Eloquent basiert auf der tatsächlichen Spalte mit dem *_typeNamen, um das Subtypmodell zu bestimmen. In meinem Fall habe ich wirklich nur einen Tisch, also ist es zwar eine nette Funktion, aber nicht in meinem Fall.
ClmentM

Antworten:

7

Sie können die polymorphen Beziehungen in Laravel verwenden, wie in den offiziellen Laravel-Dokumenten erläutert . So können Sie das machen.

Definieren Sie die Beziehungen im Modell wie angegeben

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

Hier benötigen Sie zwei Spalten in der animalsTabelle, die erste ist animable_typeund eine andere animable_id, um den Typ des Modells zu bestimmen, das zur Laufzeit daran angehängt ist.

Sie können das Hunde- oder Katzenmodell wie angegeben abrufen.

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

Danach können Sie die $animKlasse des Objekts mit überprüfen instanceof.

Dieser Ansatz hilft Ihnen bei der zukünftigen Erweiterung, wenn Sie der Anwendung einen anderen Tiertyp (z. B. Fuchs oder Löwe) hinzufügen. Es wird funktionieren, ohne Ihre Codebasis zu ändern. Dies ist der richtige Weg, um Ihre Anforderung zu erfüllen. Es gibt jedoch keinen alternativen Ansatz, um Polymorphismus und eifriges Laden zusammen zu erreichen, ohne eine polymorphe Beziehung zu verwenden. Wenn Sie keine polymorphe Beziehung verwenden , erhalten Sie mehr als einen Datenbankaufruf. Wenn Sie jedoch eine einzelne Spalte haben, die den Modaltyp unterscheidet, haben Sie möglicherweise ein falsch strukturiertes Schema. Ich schlage vor, dass Sie dies verbessern, wenn Sie es auch für die zukünftige Entwicklung vereinfachen möchten.

Das interne Modell wird neu geschrieben newInstance()und newFromBuilder()ist keine gute / empfohlene Methode. Sie müssen es überarbeiten, sobald Sie das Update vom Framework erhalten.

Kiran Maniya
quelle
1
In den Kommentaren der Frage sagte er, dass er nur eine Tabelle habe und die polymorphen Merkmale im Fall von OP nicht verwendbar seien.
schock_gone_wild
3
Ich sage nur, wie das gegebene Szenario aussieht. Ich persönlich würde auch polymorphe Beziehungen verwenden;)
schock_gone_wild
1
@KiranManiya danke für deine ausführliche Antwort. Ich interessiere mich für mehr Hintergrund. Können Sie erläutern, warum (1) das Datenbankmodell des Fragestellers falsch ist und (2) die Erweiterung der Funktionen öffentlicher / geschützter Mitglieder nicht gut / empfohlen ist?
Christoph Kluge
1
@ChristophKluge, du weißt schon. (1) Das DB-Modell ist im Zusammenhang mit Laravel-Entwurfsmustern falsch. Wenn Sie dem von laravel definierten Entwurfsmuster folgen möchten, sollten Sie ein entsprechendes DB-Schema haben. (2) Es handelt sich um eine Framework-interne Methode, die Sie zum Überschreiben vorgeschlagen haben. Ich werde es nicht tun, wenn ich jemals mit diesem Problem konfrontiert werde. Das Laravel-Framework verfügt über eine integrierte Polymorphismus-Unterstützung. Warum verwenden wir das nicht, um das Rad neu zu erfinden? Sie haben in der Antwort einen guten Hinweis gegeben, aber ich habe Code mit Nachteil nie bevorzugt, stattdessen können wir etwas codieren, das die zukünftige Erweiterung vereinfacht.
Kiran Maniya
2
Aber ... die ganze Frage bezieht sich nicht auf Laravel Design-Muster. Auch hier haben wir ein bestimmtes Szenario (möglicherweise wird die Datenbank von einer externen Anwendung erstellt). Jeder wird zustimmen, dass Polymorphismus der richtige Weg ist, wenn Sie von Grund auf neu bauen. Tatsächlich beantwortet Ihre Antwort die ursprüngliche Frage technisch nicht.
schock_gone_wild
5

Ich denke, Sie könnten die newInstanceMethode für das AnimalModell überschreiben , den Typ anhand der Attribute überprüfen und dann das entsprechende Modell initiieren.

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

Sie müssen die newFromBuilderMethode auch überschreiben .


    /**
     * 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
quelle
Ich gehe nicht, wie das funktioniert. Animal :: find (1) würde einen Fehler auslösen: "undefinierter Indextyp", wenn Sie Animal :: find (1) aufrufen. Oder fehlt mir etwas?
schock_gone_wild
@shock_gone_wild Haben Sie eine Spalte mit dem Namen typein der Datenbank?
Chris Neal
Ja, habe ich. Aber das Array $ attribute ist leer, wenn ich ein dd mache ($ attritubutes). Was durchaus Sinn macht. Wie benutzt man das in einem realen Beispiel?
schock_gone_wild
5

Wenn Sie dies wirklich tun möchten, können Sie in Ihrem Tiermodell den folgenden Ansatz verwenden.

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

Wie der OP in seinen Kommentaren feststellte: Das Datenbankdesign ist bereits festgelegt, und daher scheinen Laravels polymorphe Beziehungen hier keine Option zu sein.

Ich mag die Antwort von Chris Neal, weil ich in letzter Zeit etwas Ähnliches tun musste (meinen eigenen Datenbanktreiber schreiben, um Eloquent für Datenbank- / DBF-Dateien zu unterstützen) und viel Erfahrung mit den Interna von Laravels Eloquent ORM gesammelt habe.

Ich habe mein persönliches Flair hinzugefügt, um den Code dynamischer zu gestalten und gleichzeitig eine explizite Zuordnung pro Modell beizubehalten.

Unterstützte Funktionen, die ich schnell getestet habe:

  • Animal::find(1) funktioniert wie in Ihrer Frage gestellt
  • Animal::all() funktioniert auch
  • Animal::where(['type' => 'dog'])->get()gibt AnimalDog-objects als Sammlung zurück
  • Dynamische Objektzuordnung pro beredter Klasse, die dieses Merkmal verwendet
  • Fallback auf Animal-model, falls keine Zuordnung konfiguriert ist (oder eine neue Zuordnung in der Datenbank angezeigt wurde)

Nachteile:

  • Es wird das interne newInstance()und newFromBuilder()vollständige Modell neu geschrieben (Kopieren und Einfügen). Dies bedeutet, dass Sie den Code von Hand übernehmen müssen, wenn das Framework diese Mitgliedsfunktionen aktualisiert.

Ich hoffe es hilft und ich bin offen für Vorschläge, Fragen und zusätzliche Anwendungsfälle in Ihrem Szenario. Hier sind die Anwendungsfälle und Beispiele dafür:

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

Und dies ist ein Beispiel dafür, wie es verwendet werden kann und unter den jeweiligen Ergebnissen dafür:

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

was zu folgendem Ergebnis führt:

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

Und falls Sie möchten, dass Sie das MorphTraithier verwenden, ist natürlich der vollständige Code dafür:

<?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
quelle
Nur aus Neugier. Funktioniert dies auch mit Animal :: all ()? Ist die resultierende Sammlung eine Mischung aus 'Hunden' und 'Katzen'?
schock_gone_wild
@shock_gone_wild ziemlich gute Frage! Ich habe es lokal getestet und meiner Antwort hinzugefügt. Scheint auch zu funktionieren :-)
Christoph Kluge
2
Das Ändern der eingebauten Funktion der Laravel ist nicht korrekt. Alle Änderungen gehen verloren, sobald wir die Laravel aktualisieren, und es wird alles durcheinander bringen. Sei vorsichtig.
Navin D. Shah
Hey Navin, danke, dass du das erwähnt hast, aber es ist in meiner Antwort bereits eindeutig als Nachteil angegeben. Gegenfrage: Was ist dann der richtige Weg?
Christoph Kluge
2

Ich glaube ich weiß was du suchst. Betrachten Sie diese elegante Lösung, die Laravel-Abfragebereiche verwendet. Weitere Informationen finden Sie unter https://laravel.com/docs/6.x/eloquent#query-scopes :

Erstellen Sie eine übergeordnete Klasse mit gemeinsamer Logik:

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

Erstellen Sie ein untergeordnetes (oder mehrere) Element mit einem globalen Abfragebereich und einem savingEreignishandler:

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

(Gleiches gilt für eine andere Klasse Cat, ersetzen Sie einfach die Konstante)

Der globale Abfragebereich fungiert als Standardabfrageänderung, sodass die DogKlasse immer nach Datensätzen mit sucht type='dog'.

Angenommen, wir haben 3 Datensätze:

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

Jetzt anrufen Dog::find(1)würde zu null, da der Standard - Abfragebereich wird nicht das finden , id:1das eines ist Cat. Aufruf Animal::find(1)und Cat::find(1)beide funktionieren, obwohl nur der letzte Ihnen ein tatsächliches Cat-Objekt gibt.

Das Schöne an diesem Setup ist, dass Sie die obigen Klassen verwenden können, um Beziehungen zu erstellen wie:

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

Und diese Beziehung gibt Ihnen automatisch nur alle Tiere mit dem type='dog'(in Form von DogKlassen). Der Abfragebereich wird automatisch angewendet.

Darüber hinaus wird beim Aufruf Dog::create($properties)automatisch der typeWert 'dog'aufgrund des savingEreignishakens festgelegt (siehe https://laravel.com/docs/6.x/eloquent#events ).

Beachten Sie, dass das Anrufen Animal::create($properties)keine Standardeinstellung typehat. Daher müssen Sie diese hier manuell festlegen (was zu erwarten ist).

Flamme
quelle
0

Obwohl Sie Laravel verwenden, sollten Sie sich in diesem Fall nicht an die Abkürzungen von Laravel halten.

Dieses Problem, das Sie lösen möchten, ist ein klassisches Problem, das viele andere Sprachen / Frameworks mithilfe des Factory-Methodenmusters ( https://en.wikipedia.org/wiki/Factory_method_pattern ) lösen .

Wenn Sie möchten, dass Ihr Code leichter zu verstehen ist und keine versteckten Tricks vorhanden sind, sollten Sie ein bekanntes Muster anstelle von versteckten / magischen Tricks unter der Haube verwenden.

rubens21
quelle
0

Der bisher einfachste Weg ist es, eine Methode in der Tierklasse zu erstellen

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

Modell auflösen

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

Dies gibt je nach Modelltyp eine Instanz der Klasse Tier, Hund oder Katze zurück

Ruben Danielyan
quelle