【问题标题】:Get instance of subtype of a model with Eloquent使用 Eloquent 获取模型子类型的实例
【发布时间】:2020-02-12 15:26:24
【问题描述】:

我有一个基于animal 表的Animal 模型。

此表包含一个type 字段,该字段可以包含诸如catdog 之类的值。

我希望能够创建对象,例如:

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

然而,能够像这样获取动物:

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

但是$animal 将是DogCat 的实例,具体取决于type 字段,我可以使用instance of 进行检查,或者可以使用类型提示的方法。原因是90%的代码是共享的,但是一个会叫,一个会喵。

我知道我可以做到Dog::find($id),但这不是我想要的:我只能在获取对象后确定它的类型。我还可以获取 Animal,然后在正确的对象上运行 find(),但这是在执行两个数据库调用,这显然是我不想要的。

我试图寻找一种方法来“手动”实例化像 Dog from Animal 这样的 Eloquent 模型,但我找不到任何对应的方法。有什么我错过的想法或方法吗?

【问题讨论】:

  • @B001ᛦ 当然,我的 Dog 或 Cat 类会有相应的接口,在这里我看不出它有什么帮助?
  • @ClmentM 看起来像一对多的多态关系laravel.com/docs/6.x/…
  • @vivek_23 并非如此,在这种情况下,它有助于过滤给定类型的 cmets,但您已经知道最终需要 cmets。不适用于此处。
  • @ClmentM 我认为确实如此。动物可以是猫或狗。因此,当您从动物表中检索动物类型时,它会根据数据库中存储的内容为您提供 Dog 或 Cat 的实例。最后一行说 Comment 模型上的可评论关系将返回 Post 或 Video 实例,具体取决于拥有评论的模型类型。
  • @vivek_23 我深入研究了文档并试了一下,但 Eloquent 是基于具有*_type 名称的实际列来确定子类型模型。在我的情况下,我真的只有一张桌子,所以虽然这是一个不错的功能,但对我来说不是。

标签: php laravel eloquent


【解决方案1】:

正如 OP 在他的 cmets 中所说:数据库设计已经设置好,因此 Laravel 的 Polymorphic Relationships 似乎不是这里的选项。

喜欢 Chris Neal 的回答,因为我最近不得不做一些类似的事情(编写我自己的数据库驱动程序来支持 Eloquent 用于 dbase/DBF 文件)并且在 Laravel 的内部获得了很多经验雄辩的 ORM。

我在其中添加了我的个人风格,以使代码更具动态性,同时保持每个模型的显式映射。

我快速测试的支持的功能:

  • Animal::find(1) 按照您的问题进行操作
  • Animal::all() 也可以
  • Animal::where(['type' => 'dog'])->get() 将返回 AnimalDog-objects 作为一个集合
  • 每个使用此特征的 eloquent 类的动态对象映射
  • 回退到Animal-model,以防未配置映射(或数据库中出现新映射)

缺点:

  • 它正在完全重写模型的内部newInstance()newFromBuilder()(复制和粘贴)。这意味着,如果框架对此成员函数有任何更新,您将需要手动采用代码。

我希望它对您有所帮助,并且我愿意为您的方案中的任何建议、问题和其他用例提供帮助。以下是它的用例和示例:

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

这是一个如何使用它的例子,下面是它的相应结果:

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

结果如下:

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

如果你想使用MorphTrait,这里当然是它的完整代码:

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

【讨论】:

  • 只是出于好奇。这也适用于 Animal::all() 生成的集合是 'Dogs' 和 'Cats' 的混合物吗?
  • @shock_gone_wild 很好的问题!我在本地对其进行了测试并将其添加到我的答案中。似乎也可以:-)
  • 修改 laravel 的内置函数不是正确的方法。一旦我们更新 laravel,所有的更改都会松动,它会搞砸一切。请注意。
  • 嘿 Navin,感谢您提及这一点,但在我的回答中已经明确表示为劣势。反问:那么正确的方法是什么?
【解决方案2】:

您可以使用 Laravel 中的多态关系,如 Official Laravel Docs 中所述。以下是您可以这样做的方法。

按照给定的方式定义模型中的关系

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

这里您需要 animals 表中的两列,第一列是 animable_type,另一列是 animable_id在运行时确定附加到它的模型的类型。

您可以按照给定的方式获取 Dog 或 Cat 模型,

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

之后,您可以使用 instanceof 来检查 $anim 对象的类。

如果您在应用程序中添加另一种动物类型(即狐狸或狮子),此方法将帮助您在未来进行扩展。它可以在不更改您的代码库的情况下工作。这是实现您的要求的正确方法。但是,没有其他方法可以在不使用多态关系的情况下同时实现多态性和预加载。如果你不使用多态关系,你最终会得到不止一个数据库调用。但是,如果您有一个区分模式类型的列,则可能您的结构化模式有误。如果您还想为将来的开发简化它,我建议您改进它。

重写模型的内部 newInstance()newFromBuilder() 不是一个好的/推荐的方法,一旦你得到更新你就必须重新工作来自框架。

【讨论】:

  • 在他所说的问题的cmets中,他只有一个表,并且多态特征在OP的情况下不可用。
  • 我只是在说明,给定的场景是什么样的。我个人也会使用多态关系;)
  • @KiranManiya 感谢您的详细回答。我对更多背景感兴趣。您能否详细说明为什么 (1) 提问者数据库模型是错误的以及 (2) 扩展公共/受保护的成员函数不好/不推荐?
  • @ChristophKluge,你已经知道了。 (1) DB 模型在 laravel 设计模式的上下文中是错误的。如果你想遵循 laravel 定义的设计模式,你应该有根据它的 DB 模式。 (2) 这是您建议覆盖的框架内部方法。如果我遇到这个问题,我不会这样做。 Laravel 框架具有内置的多态性支持,那么我们为什么不使用它,而是重新发明轮子呢?您在答案中提供了一个很好的线索,但我从不喜欢有缺点的代码,而是我们可以编写一些有助于简化未来扩展的代码。
  • 但是......整个问题与 Laravel 设计模式无关。同样,我们有一个给定的场景(也许数据库是由外部应用程序创建的)。如果您从头开始构建,每个人都会同意多态性将是可行的方法。事实上,从技术上讲,您的回答并不能回答原始问题。
【解决方案3】:

我认为你可以覆盖Animal模型上的newInstance方法,并从属性中检查类型,然后初始化相应的模型。

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

您还需要覆盖 newFromBuilder 方法。


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

【讨论】:

  • 我不明白这是如何工作的。如果调用 Animal::find(1),Animal::find(1) 会抛出错误:“未定义的索引类型”。还是我错过了什么?
  • @shock_gone_wild 你的数据库中有一个名为type 的列吗?
  • 是的,我有。但是如果我执行 dd($attritubutes),$attributes 数组是空的。这完全有道理。您如何在实际示例中使用它?
【解决方案4】:

如果您真的想这样做,可以在 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);
    }
}

【讨论】:

    【解决方案5】:

    我想我知道你在寻找什么。考虑这个使用 Laravel 查询范围的优雅解决方案,请参阅 https://laravel.com/docs/6.x/eloquent#query-scopes 了解更多信息:

    创建一个持有共享逻辑的父类:

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

    创建一个具有全局查询范围和saving 事件处理程序的子(或多个):

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

    (同样适用于另一个类Cat,只需替换常量)

    全局查询范围充当默认查询修改,这样Dog 类将始终查找具有type='dog' 的记录。

    假设我们有 3 条记录:

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

    现在调用Dog::find(1) 将导致null,因为默认查询范围不会找到id:1,即Cat。调用 Animal::find(1)Cat::find(1) 都可以,尽管只有最后一个会为您提供一个实际的 Cat 对象。

    这个设置的好处是你可以使用上面的类来创建如下关系:

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

    并且这种关系将自动只为您提供具有type='dog' 的所有动物(以Dog 类的形式)。查询范围自动应用。

    此外,由于saving 事件挂钩,调用Dog::create($properties) 会自动将type 设置为'dog'(请参阅https://laravel.com/docs/6.x/eloquent#events)。

    请注意,调用Animal::create($properties) 没有默认的type,因此您需要在此处手动设置(这是意料之中的)。

    【讨论】:

      【解决方案6】:

      目前最简单的方法是在 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;
      }
      

      解析模型

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

      这将根据模型类型返回动物、狗或猫类的实例

      【讨论】:

        【解决方案7】:

        虽然你使用的是 Laravel,但在这种情况下,我认为你不应该拘泥于 Laravel 的捷径。

        您尝试解决的这个问题是许多其他语言/框架使用工厂方法模式 (https://en.wikipedia.org/wiki/Factory_method_pattern) 解决的经典问题。

        如果您想让您的代码更易于理解并且没有隐藏技巧,您应该使用众所周知的模式而不是隐藏/魔术技巧。

        【讨论】:

          猜你喜欢
          • 2015-08-08
          • 2013-07-09
          • 2011-10-28
          • 2016-09-04
          • 2012-03-29
          • 2014-07-14
          • 2017-09-18
          • 2019-01-20
          • 1970-01-01
          相关资源
          最近更新 更多