Obtenir une instance de sous-type d'un modèle avec Eloquent

22

J'ai un Animalmodèle, basé sur le animaltableau.

Cette table contient un typechamp, qui peut contenir des valeurs telles que chat ou chien .

J'aimerais pouvoir créer des objets tels que:

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

Pourtant, pouvoir aller chercher un animal comme celui-ci:

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

Mais où $animalserait une instance de Dogou en Catfonction du typechamp, que je peux vérifier en utilisant instance ofou qui fonctionnera avec des méthodes de type hinted. La raison en est que 90% du code est partagé, mais l'un peut aboyer et l'autre miauler.

Je sais que je peux le faire Dog::find($id), mais ce n'est pas ce que je veux: je ne peux déterminer le type de l'objet qu'une fois récupéré. Je pourrais également récupérer l'animal, puis exécuter find()sur le bon objet, mais cela fait deux appels à la base de données, ce que je ne veux évidemment pas.

J'ai essayé de trouver un moyen d'instancier "manuellement" un modèle Eloquent comme Dog from Animal, mais je n'ai trouvé aucune méthode correspondante. Une idée ou une méthode que j'ai ratée s'il vous plaît?

ClmentM
la source
@ B001 ᛦ Bien sûr, ma classe Chien ou Chat va avoir des interfaces correspondantes, je ne vois pas comment ça aide ici?
ClmentM
@ClmentM Ressemble à une ou plusieurs relations polymorphes laravel.com/docs/6.x/…
vivek_23
@ vivek_23 Pas vraiment, dans ce cas, cela aide à filtrer les commentaires d'un type donné, mais vous savez déjà que vous voulez des commentaires à la fin. Ne s'applique pas ici.
ClmentM
@ClmentM Je pense que oui. L'animal peut être un chat ou un chien. Ainsi, lorsque vous récupérez le type d'animal de la table des animaux, cela vous donnera une instance de Dog ou Cat selon ce qui est stocké dans la base de données. La dernière ligne indique que la relation commentable sur le modèle de commentaire renverra une instance de publication ou de vidéo, selon le type de modèle propriétaire du commentaire.
vivek_23
@ vivek_23 J'ai plongé davantage dans la documentation et je l'ai essayé, mais Eloquent est basé sur la colonne réelle avec le *_typenom pour déterminer le modèle de sous-type. Dans mon cas, je n'ai vraiment qu'une seule table, alors même si c'est une fonctionnalité intéressante, pas dans mon cas.
ClmentM

Réponses:

7

Vous pouvez utiliser les relations polymorphes dans Laravel comme expliqué dans les documents officiels Laravel . Voici comment procéder.

Définissez les relations dans le modèle comme indiqué

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

Ici, vous aurez besoin de deux colonnes dans le animalstableau, la première est animable_typeet une autre consiste animable_idà déterminer le type de modèle qui lui est attaché lors de l'exécution.

Vous pouvez récupérer le modèle Dog ou Cat comme indiqué,

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

Après cela, vous pouvez vérifier la $animclasse de l' objet à l'aide de instanceof.

Cette approche vous aidera pour une expansion future si vous ajoutez un autre type d'animal (c.-à-d. Renard ou lion) dans l'application. Cela fonctionnera sans changer votre base de code. C'est la bonne façon de répondre à vos besoins. Cependant, il n'y a pas d'approche alternative pour réaliser le polymorphisme et le chargement avide ensemble sans utiliser une relation polymorphe. Si vous n'utilisez pas de relation polymorphe , vous vous retrouverez avec plus d'un appel de base de données. Cependant, si vous avez une seule colonne qui différencie le type modal, vous avez peut-être un schéma structuré incorrect. Je vous suggère de l'améliorer si vous souhaitez également le simplifier pour un développement futur.

Réécrire l'interne du modèle newInstance()et ce newFromBuilder()n'est pas un bon moyen / recommandé et vous devez le retravailler une fois que vous aurez la mise à jour du framework.

Kiran Maniya
la source
1
Dans les commentaires de la question, il a dit qu'il n'a qu'une seule table et que les caractéristiques polymorphes ne sont pas utilisables dans le cas de l'OP.
shock_gone_wild
3
Je dis simplement à quoi ressemble le scénario donné.
Personnellement
1
@KiranManiya merci pour votre réponse détaillée. Je suis intéressé par plus de contexte. Pouvez-vous expliquer pourquoi (1) le modèle de base de données des intervenants est incorrect et (2) l'extension des fonctions membres publiques / protégées n'est pas bonne / recommandée?
Christoph Kluge
1
@ChristophKluge, vous le savez déjà. (1) Le modèle DB est erroné dans le contexte des modèles de conception de laravel. Si vous souhaitez suivre le modèle de conception défini par laravel, vous devez avoir un schéma DB en fonction de celui-ci. (2) Il s'agit d'une méthode interne du framework que vous avez suggéré de remplacer. Je ne le ferai pas si jamais je fais face à ce problème. Le framework Laravel a un support de polymorphisme intégré, alors pourquoi ne pas l'utiliser plutôt réinventer la roue? Vous avez donné un bon indice dans la réponse, mais je n'ai jamais préféré coder avec un inconvénient à la place, nous pouvons coder quelque chose qui aide à simplifier l'expansion future.
Kiran Maniya
2
Mais ... toute la question ne concerne pas les modèles Laravel Design. Encore une fois, nous avons un scénario donné (peut-être que la base de données est créée par une application externe). Tout le monde conviendra que le polymorphisme serait la voie à suivre si vous construisez à partir de zéro. En fait, votre réponse ne répond techniquement pas à la question d'origine.
shock_gone_wild
5

Je pense que vous pouvez remplacer la newInstanceméthode sur le Animalmodèle et vérifier le type à partir des attributs, puis lancer le modèle correspondant.

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

Vous devrez également remplacer la newFromBuilderméthode.


    /**
     * 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
la source
Je ne sais pas comment cela fonctionne. Animal :: find (1) lancerait une erreur: "type d'index non défini" si vous appelez Animal :: find (1). Ou est-ce que je manque quelque chose?
shock_gone_wild
@shock_gone_wild Avez-vous une colonne nommée typedans la base de données?
Chris Neal
Oui j'ai. Mais le tableau $ attributes est vide si je fais un dd ($ attritubutes). Ce qui est parfaitement logique. Comment utilisez-vous cela dans un exemple réel?
shock_gone_wild
5

Si vous voulez vraiment le faire, vous pouvez utiliser l'approche suivante dans votre modèle 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
la source
5

Comme l'OP l'a déclaré dans ses commentaires: La conception de la base de données est déjà définie et, par conséquent, les relations polymorphes de Laravel ne semblent pas être une option ici.

J'aime la réponse de Chris Neal parce que j'ai dû faire quelque chose de similaire récemment (écrire mon propre pilote de base de données pour prendre en charge Eloquent pour les fichiers dbase / DBF) et j'ai acquis beaucoup d'expérience avec les composants internes de Laroquel's Eloquent ORM.

J'y ai ajouté ma saveur personnelle pour rendre le code plus dynamique tout en conservant un mappage explicite par modèle.

Fonctionnalités prises en charge que j'ai rapidement testées:

  • Animal::find(1) fonctionne comme demandé dans votre question
  • Animal::all() fonctionne aussi
  • Animal::where(['type' => 'dog'])->get()renverra AnimalDog-objects en tant que collection
  • Mappage d'objet dynamique par classe éloquente qui utilise ce trait
  • AnimalRetour à -model dans le cas où aucun mappage n'est configuré (ou si un nouveau mappage apparaît dans la base de données)

Désavantages:

  • C'est la réécriture interne newInstance()et newFromBuilder()complète du modèle (copier-coller). Cela signifie que s'il y aura une mise à jour du cadre vers ces fonctions membres, vous devrez adopter le code à la main.

J'espère que cela aide et je suis prêt à toute suggestion, question et cas d'utilisation supplémentaires dans votre scénario. Voici les cas d'utilisation et des exemples:

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

Et ceci est un exemple de la façon dont il peut être utilisé et ci-dessous les résultats respectifs pour cela:

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

ce qui donne les résultats suivants:

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

Et au cas où vous voudriez utiliser le MorphTraitvoici bien sûr le code complet:

<?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
la source
Juste par curiosité. Est-ce que cela fonctionne aussi avec Animal :: all () La collection résultante est-elle un mélange de 'Dogs' et 'Cats'?
shock_gone_wild
@shock_gone_wild assez bonne question! Je l'ai testé localement et l'ai ajouté à ma réponse. Semble également fonctionner :-)
Christoph Kluge
2
la modification de la fonction intégrée du laravel n'est pas correcte. Toutes les modifications seront perdues une fois que nous aurons mis à jour le laravel et cela gâchera tout. Être conscient.
Navin D. Shah
Hé Navin, merci d'avoir mentionné cela, mais c'est déjà clairement indiqué comme un inconvénient dans ma réponse. Contre question: Quelle est la bonne façon alors?
Christoph Kluge
2

Je pense que je sais ce que vous cherchez. Considérez cette élégante solution qui utilise les étendues de requête Laravel, voir https://laravel.com/docs/6.x/eloquent#query-scopes pour plus d'informations:

Créez une classe parent contenant une logique partagée:

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

Créez un enfant (ou plusieurs) avec une portée de requête globale et un savinggestionnaire d'événements:

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

(il en va de même pour une autre classe Cat, il suffit de remplacer la constante)

La portée de requête globale agit comme une modification de requête par défaut, de sorte que la Dogclasse recherchera toujours les enregistrements avec type='dog'.

Disons que nous avons 3 enregistrements:

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

L'appel Dog::find(1)entraînerait maintenant null, car la portée de requête par défaut ne trouverait pas le id:1qui est un Cat. L'appel Animal::find(1)et Cat::find(1)fonctionnera tous les deux, bien que seul le dernier vous donne un véritable objet Cat.

La bonne chose de cette configuration est que vous pouvez utiliser les classes ci-dessus pour créer des relations comme:

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

Et cette relation ne vous donnera automatiquement que tous les animaux avec le type='dog'(sous forme de Dogclasses). La portée de la requête est automatiquement appliquée.

De plus, l'appel Dog::create($properties)définira automatiquement le typeen 'dog'raison du savinghook d'événement (voir https://laravel.com/docs/6.x/eloquent#events ).

Notez que l'appel Animal::create($properties)n'a pas de valeur par défaut type, vous devez donc le définir manuellement (ce qui est normal).

Flamme
la source
0

Bien que vous utilisiez Laravel, dans ce cas, je pense que vous ne devriez pas vous en tenir aux raccourcis Laravel.

Ce problème que vous essayez de résoudre est un problème classique que de nombreux autres langages / frameworks résolvent à l'aide du modèle de méthode Factory ( https://en.wikipedia.org/wiki/Factory_method_pattern ).

Si vous voulez que votre code soit plus facile à comprendre et sans astuces cachées, vous devez utiliser un modèle bien connu au lieu d'astuces cachées / magiques sous le capot.

rubens21
la source
0

Le moyen le plus simple consiste à faire de la méthode en 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;
}

Résolution du modèle

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

Cela renverra une instance de classe Animal, Dog ou Cat selon le type de modèle

Ruben Danielyan
la source