Laravel belongsTo with condition and eager load

lkartono picture lkartono · May 18, 2016 · Viewed 11.9k times · Source

I have a Post model associated to a Section model, which depend on an extra condition to work:

<?php
class Post extends Base
{
    public function section()
    {
        return $this->belongsTo('App\Models\Section', 'id_cat')->where('website', $this->website);
    }
}

When I want to retrieve a Post and get it's associated section, I can do it as:

$post = Post::first();
echo $post->section->name; // Output the section's name

However, when trying to get the section using an eager load:

Post::with(['section'])->chunk(1000, function ($posts) {
    echo $post->section->name;
});

Laravel throw the following exception :

PHP error:  Trying to get property of non-object

When I do a debug of a Post object returned by the above eager load query, I notice that the section relationship is null. Note that it is working fine if I remove the condition from the belongsTo association.

Do you guys have any ideas why it's happening?

Answer

mpyw picture mpyw · May 30, 2018

You can achieve it by defining custom relationship.

BelongsToWith.php

<?php

declare(strict_types=1);

namespace App\Database\Eloquent\Relations;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class BelongsToWith extends BelongsTo
{
    /**
     * @var array [$foreignColumn => $ownerColumn, ...] assoc or [$column, ...] array
     */
    protected $conditions = [];

    public function __construct(array $conditions, Builder $query, Model $child, string $foreignKey, string $ownerKey, string $relation)
    {
        $this->conditions = $conditions;

        parent::__construct($query, $child, $foreignKey, $ownerKey, $relation);
    }

    public function addConstraints()
    {
        if (static::$constraints) {
            // Add base constraints
            parent::addConstraints();

            // Add extra constraints
            foreach ($this->conditions as $key => $value) {
                if (is_int($key)) {
                    $key = $value;
                }
                $this->getQuery()->where($this->related->getTable() . '.' . $value, '=', $this->child->{$key});
            }
        }
    }

    public function addEagerConstraints(array $models)
    {
        // Ignore empty models
        if ([null] === $this->getEagerModelKeys($models)) {
            parent::addEagerConstraints($models);
            return;
        }

        $this->getQuery()->where(function (Builder $query) use ($models) {
            foreach ($models as $model) {
                $query->orWhere(function (Builder $query) use ($model) {
                    // Add base constraints
                    $query->where($this->related->getTable() . '.' . $this->ownerKey, $model->getAttribute($this->foreignKey));

                    // Add extra constraints
                    foreach ($this->conditions as $key => $value) {
                        if (is_int($key)) {
                            $key = $value;
                        }
                        $query->where($this->related->getTable() . '.' . $value, $model->getAttribute($key));
                    }
                });
            }
        });
    }

    public function match(array $models, Collection $results, $relation)
    {
        $dictionary = [];

        foreach ($results as $result) {
            // Base constraints
            $keys = [$result->getAttribute($this->ownerKey)];

            // Extra constraints
            foreach ($this->conditions as $key => $value) {
                $keys[] = $result->getAttribute($value);
            }

            // Build nested dictionary
            $current = &$dictionary;
            foreach ($keys as $key) {
                $current = &$current[$key];
            }
            $current = $result;
            unset($current);
        }

        foreach ($models as $model) {
            $current = $dictionary;

            // Base constraints
            if (!isset($current[$model->{$this->foreignKey}])) {
                continue;
            }
            $current = $current[$model->{$this->foreignKey}];

            // Extra constraints
            foreach ($this->conditions as $key => $value) {
                if (is_int($key)) {
                    $key = $value;
                }
                if (!isset($current[$model->{$key}])) {
                    continue 2;
                }
                $current = $current[$model->{$key}];
            }

            // Set passed result
            $model->setRelation($relation, $current);
        }

        return $models;
    }
}

HasExtendedRelationships.php

<?php

declare(strict_types=1);

namespace App\Database\Eloquent\Concerns;

use App\Database\Eloquent\Relations\BelongsToWith;
use Illuminate\Support\Str;

trait HasExtendedRelationships
{
    public function belongsToWith(array $conditions, $related, $foreignKey = null, $ownerKey = null, $relation = null): BelongsToWith
    {
        if ($relation === null) {
            $relation = $this->guessBelongsToRelation();
        }

        $instance = $this->newRelatedInstance($related);

        if ($foreignKey === null) {
            $foreignKey = Str::snake($relation) . '_' . $instance->getKeyName();
        }

        $ownerKey = $ownerKey ?: $instance->getKeyName();

        return new BelongsToWith($conditions, $instance->newQuery(), $this, $foreignKey, $ownerKey, $relation);
    }
}

Then:

class Post extends Base
{
    use HasExtendedRelationships;

    public function section(): BelongsToWith
    {
        return $this->belongsToWith(['website'], App\Models\Section::class, 'id_cat');
    }
}

 

$posts = Post::with('section')->find([1, 2]);

Your Eager Loading query will be like:

select * from `sections`
where (
    (
        `sections`.`id` = {$posts[0]->id_cat}
        and `sections`.`website` = {$posts[0]->website}
    )
    or
    (
        `sections`.`id` = {$posts[1]->id_cat}
        and `sections`.`website` = {$posts[1]->website}
    )
)