具有 Eloquent 关系的空对象模式

2021-12-18 00:00:00 php laravel laravel-5 eloquent

经常有这样的情况,某个 eloquent 模型的关系未设置(即在书籍表中,author_id 为空),因此调用诸如 $model->relation 之类的东西会返回空.

There is often the case where an certain eloquent model's relation is unset (i.e. in a books table, author_id is null) and thus calling something like $model->relation returns null.

例如假设 Book 模型有一个我可能想要做的 author() (hasOne) 关系

E.g. say a Book model has an author() (hasOne) relation I might want to do

$author = Book::find(1)->author->name;

如果 Book 1 没有设置作者,它将抛出试图获取非对象的属性"错误.有没有办法避免这种情况并默认为空白的 Author 所以我总是能够在它上面调用 name,无论是否为特定设置了关系模型?

If Book 1 has no author set it will throw a "trying to get property of non object" error. Is there a way to avoid this and default to a blank Author so I'll always be able to call name on it regardless of whether the relation has been set for the specific model?

本质上,我想避免在调用进一步的方法/属性之前检查 $book->author 是否是实际的 Author 的条件.如果没有设置关系,它应该默认为一个新的 Author 实例.

Essentially I want to avoid conditionals to check if $book->author is an actual Author before calling further methods/properties on it. It should default to a new Author instance if the relation isn't set.

我尝试了类似的东西:

public function getAuthorAttribute($author)
{
    return $author ?: new Author;
}

然而这是行不通的;$author 被作为 null 传入,即使它是在模型上设置的.大概是因为它是一种关系,而不是一本书的直接属性.我需要像

however this doesn't work; $author is being passed in as null, even if it's set on the model. Presumably because it's a relation rather than a direct property of a book. I'd need something like

public function getAuthorAttribute()
{
    return $this->author()->first() ?: new Author;
}

这看起来很不雅观,而且似乎会覆盖任何导致性能不佳的急切加载.

which seems pretty inelegant and seems like it would override any eager loading resulting in poor performance.

推荐答案

更新

从 Laravel 5.3.23 开始,现在有一个内置的方法来实现这一点(至少对于 HasOne 关系).withDefault() 方法已添加到 HasOne 关系中.对于您的 Book/Author 示例,您的代码如下所示:

Update

As of Laravel 5.3.23, there is now a built in way to accomplish this (at least for HasOne relationships). A withDefault() method was added to the HasOne relationship. In the case of your Book/Author example, your code would look like:

public function author() {
    return $this->hasOne(Author::class)->withDefault();
}

如果在数据库中找不到记录,此关系现在将返回一个相当空的(设置了键)Author 模型.此外,如果你想用一些额外的数据填充你的空模型,你可以传入一个属性数组,或者你可以传入一个闭包,它返回你想要的默认设置(没有成为作者模型).

This relationship will now return a fairly empty (keys are set) Author model if no record is found in the database. Additionally, you can pass in an array of attributes if you'd like to populate your empty model with some extra data, or you can pass in a Closure that returns what you'd like to have your default set to (doesn't have to be an Author model).

直到有一天它成为文档,有关更多信息,您可以查看与更改相关的拉取请求:16198 和 16382.

Until this makes it into the documentation one day, for more information you can check out the pull requests related to the change: 16198 and 16382.

在撰写本文时,这仅针对 HasOne 关系实施.它最终可能会迁移到 BelongsToMorphOneMorphTo 关系,但我不能肯定.

At the time of this writing, this has only been implemented for the HasOne relationship. It may eventually migrate to the BelongsTo, MorphOne, and MorphTo relationships, but I can't say for sure.

据我所知,没有内置的方法可以做到这一点,但有几种解决方法.

There's no built in way that I know of to do this, but there are a couple workarounds.

正如您所发现的,使用访问器的问题在于传递给访问器的 $value 将始终为 null,因为它是从模型上的属性数组.这个属性数组不包括关系,无论它们是否已经加载.

The problem with using an accessor, as you've found out, is that the $value passed to the accessor will always be null, since it is populated from the array of attributes on the model. This array of attributes does not include relationships, whether they're already loaded or not.

如果您想尝试使用访问器解决此问题,您只需忽略传入的任何值,并自行检查关系.

If you want to attempt to solve this with an accessor, you would just ignore whatever value is passed in, and check the relationship yourself.

public function getAuthorAttribute($value)
{
    $key = 'author';

    /**
     * If the relationship is already loaded, get the value. Otherwise, attempt
     * to load the value from the relationship method. This will also set the
     * key in $this->relations so that subsequent calls will find the key.
     */
    if (array_key_exists($key, $this->relations)) {
        $value = $this->relations[$key];
    } elseif (method_exists($this, $key)) {
        $value = $this->getRelationshipFromMethod($key);
    }

    $value = $value ?: new Author();

    /**
     * This line is optional. Do you want to set the relationship value to be
     * the new Author, or do you want to keep it null? Think of what you'd
     * want in your toArray/toJson output...
     */
    $this->setRelation($key, $value);

    return $value;
}

现在,在访问器中执行此操作的问题在于您需要为每个模型上的每个 hasOne/belongsTo 关系定义一个访问器.

Now, the problem with doing this in the accessor is that you need to define an accessor for every hasOne/belongsTo relationship on every model.

第二个较小的问题是访问器仅在访问属性时使用.因此,例如,如果您要预先加载关系,然后 dd()toArray/toJson 模型,它仍然会显示 null 表示关系,而不是空的作者.

A second, smaller, issue is that the accessor is only used when accessing the attribute. So, for example, if you were to eager load the relationship, and then dd() or toArray/toJson the model, it would still show null for the relatioinship, instead of an empty Author.

第二个选项是覆盖 Model 上的一些方法,而不是使用属性访问器.这解决了使用属性访问器的两个问题.

A second option, instead of using attribute accessors, would be to override some methods on the Model. This solves both of the problems with using an attribute accessor.

您可以创建自己的基础 Model 类来扩展 Laravel Model 并覆盖这些方法,然后所有其他模型将扩展您的基础 Model 类,而不是 Laravel 的 Model 类.

You can create your own base Model class that extends the Laravel Model and overrides these methods, and then all of your other models will extend your base Model class, instead of Laravel's Model class.

要处理预先加载的关系,您需要覆盖 setRelation() 方法.如果使用 Laravel >= 5.2.30,这也将处理延迟加载关系.如果使用 Laravel <5.2.30,您还需要覆盖延迟加载关系的 getRelationshipFromMethod() 方法.

To handle eager loaded relationships, you would need to override the setRelation() method. If using Laravel >= 5.2.30, this will also handle lazy loaded relationships. If using Laravel < 5.2.30, you will also need to override the getRelationshipFromMethod() method for lazy loaded relationships.

MyModel.php

class MyModel extends Model
{
    /**
     * Handle eager loaded relationships. Call chain:
     * Model::with() => Builder::with(): sets builder eager loads
     * Model::get() => Builder::get() => Builder::eagerLoadRelations() => Builder::loadRelation()
     *     =>Relation::initRelation() => Model::setRelation()
     *     =>Relation::match() =>Relation::matchOneOrMany() => Model::setRelation()
     */
    public function setRelation($relation, $value)
    {
        /**
         * Relationships to many records will always be a Collection, even when empty.
         * Relationships to one record will either be a Model or null. When attempting
         * to set to null, override with a new instance of the expected model.
         */
        if (is_null($value)) {
            // set the value to a new instance of the related model
            $value = $this->$relation()->getRelated()->newInstance();
        }

        $this->relations[$relation] = $value;

        return $this;
    }

    /**
     * This override is only needed in Laravel < 5.2.30. In Laravel
     * >= 5.2.30, this method calls the setRelation method, which
     * is already overridden and contains our logic above.
     *
     * Handle lazy loaded relationships. Call chain:
     * Model::__get() => Model::getAttribute() => Model::getRelationshipFromMethod();
     */
    protected function getRelationshipFromMethod($method)
    {
        $results = parent::getRelationshipFromMethod($method);

        /**
         * Relationships to many records will always be a Collection, even when empty.
         * Relationships to one record will either be a Model or null. When the
         * result is null, override with a new instance of the related model.
         */
        if (is_null($results)) {
            $results = $this->$method()->getRelated()->newInstance();
        }

        return $this->relations[$method] = $results;
    }
}

Book.php

class Book extends MyModel
{
    //
}

相关文章