Null object pattern with Eloquent relations

后端 未结 3 1377
囚心锁ツ
囚心锁ツ 2020-12-06 20:08

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

相关标签:
3条回答
  • 2020-12-06 20:32

    You can achieve this using model factories.

    Define an author factory inside your ModelFactory.php

    $factory->define(App\Author::class, function (Faker\Generator $faker) {
        return [
            'name' => $faker->firstName, //or null
            'avatar' => $faker->imageUrl() //or null
        ];
    });
    

    add values for all the needed attributes I am using dummy values from Faker but you can use anything you want.

    Then inside your book model you can return an instance of Author like this:

    public function getAuthorAttribute($author)
    {
        return $author ?: factory(App\Author::class)->make();
    }
    
    0 讨论(0)
  • 2020-12-06 20:35

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

    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).

    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.

    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.


    Original

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

    Using an Accessor

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

    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.

    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.

    Overriding Model Methods

    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.

    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.

    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
    {
        //
    }
    
    0 讨论(0)
  • 2020-12-06 20:51

    I had the same problem in my project. In my views there's some rows that are accesing to dinamics properties from null relationships, but instead of returning an empty field, the app was thrwoing and exception.

    I just added a foreach loop in my controller as a temporal solution that verifies in every value of the collection if the relationship is null. If this case is true, it assigns a new instance of the desire model to that value.

        foreach ($shifts as $shift)
        {
            if (is_null($shift->productivity)) {
                $shift->productivity = new Productivity();
            }
        }
    

    This way when I access to $this->productivity->something in my view when the relationship is unset, I get a empty value instead of an exception without putting any logic in my views nor overriding methods.

    Waiting for a better solution to do this automatically.

    0 讨论(0)
提交回复
热议问题