Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Laravel dynamic relationships - access model attributes on eager load

I have an Eloquent relationship on my Laravel model which is dynamic - that is, the value of a particular database field determines which model will get loaded. I am able to load this relationship fine when I first instantiate the model instance and then reference the relation, but it does not work when I eager load that relationship.

Specifically, I have a Product model. That product might or might not be a parent to another product. If the parent_id of the Product is set to 0 then that product is considered a parent part (whether or not it has children). If the parent_id is set to a different product's id, then that product is a child. I need to be able to access Product::with('parent') and know that the parent relation will return with either itself (yes, duplicated data) or a different product if it is a child.

Here is my relationship so far:

public function parent()
{
    if ($this->parent_id > 0) {
        return $this->belongsTo('App\Product', 'parent_id', 'id');
    } else {
        return $this->belongsTo('App\Product', 'id', 'id');
    }
}

When I am eager loading, $this->parent_id is always undefined and therefore this relation will only ever return itself even if it actually is a parent product.

Is there any way to access a model's attributes before a relation is eager loaded? I thought about working in a separate query before I return the relation, but I realized that I don't have access to the product's id to even run that query.

If that is not possible, what are some other ways to address this type of problem? It does not appear that this could be solved through a traditional polymorphic relationship. I only have two possible ideas:

  • Add some sort of constraint to the belongsTo relation where I dynamically determine a foreign key.
  • Create my own custom relationship that uses a foreign key based on a different database field.

I honestly have no idea how I would implement either of those. Am I going about this the right way? Is there something I am overlooking?


After thinking this over more, I think the simplest way to put the question is: is there any way to dynamically select a foreign key for a relationship inside the relation itself at runtime? My use cases don't allow for me to use eager loading constraints when I call the relation - the constraints need to apply to the relation itself.

like image 312
Andy Noelker Avatar asked Feb 16 '16 16:02

Andy Noelker


1 Answers

Because of the way eager loading works, there isn't anything you can really do to the SQL being run to get done what you're looking for.

When you do Product::with('parent')->get(), it runs two queries.

First, it runs the query to get all the products:

select * from `products`

Next, it runs a query to get the eager loaded parents:

select * from `products` where `products`.`id` in (?, ?, ?)

The number of parameters (?) corresponds to the number of results from the first query. Once the second set of models has been retrieved, the match() function is used to relate the objects to each other.

In order to do what you want, you're going to have to create a new relationship and override the match() method. This will handle the eager loading aspect. Additionally, you'll need to override the addConstraints method to handle the lazy loading aspect.

First, create a custom relationship class:

class CustomBelongsTo extends BelongsTo
{
    // Override the addConstraints method for the lazy loaded relationship.
    // If the foreign key of the model is 0, change the foreign key to the
    // model's own key, so it will load itself as the related model.

    /**
     * Set the base constraints on the relation query.
     *
     * @return void
     */
    public function addConstraints()
    {
        if (static::$constraints) {
            // For belongs to relationships, which are essentially the inverse of has one
            // or has many relationships, we need to actually query on the primary key
            // of the related models matching on the foreign key that's on a parent.
            $table = $this->related->getTable();

            $key = $this->parent->{$this->foreignKey} == 0 ? $this->otherKey : $this->foreignKey;

            $this->query->where($table.'.'.$this->otherKey, '=', $this->parent->{$key});
        }
    }

    // Override the match method for the eager loaded relationship.
    // Most of this is copied from the original method. The custom
    // logic is in the elseif.

    /**
     * Match the eagerly loaded results to their parents.
     *
     * @param  array   $models
     * @param  \Illuminate\Database\Eloquent\Collection  $results
     * @param  string  $relation
     * @return array
     */
    public function match(array $models, Collection $results, $relation)
    {
        $foreign = $this->foreignKey;

        $other = $this->otherKey;

        // First we will get to build a dictionary of the child models by their primary
        // key of the relationship, then we can easily match the children back onto
        // the parents using that dictionary and the primary key of the children.
        $dictionary = [];

        foreach ($results as $result) {
            $dictionary[$result->getAttribute($other)] = $result;
        }

        // Once we have the dictionary constructed, we can loop through all the parents
        // and match back onto their children using these keys of the dictionary and
        // the primary key of the children to map them onto the correct instances.
        foreach ($models as $model) {
            if (isset($dictionary[$model->$foreign])) {
                $model->setRelation($relation, $dictionary[$model->$foreign]);
            }
            // If the foreign key is 0, set the relation to a copy of the model
            elseif($model->$foreign == 0) {
                // Make a copy of the model.
                // You don't want recursion in your relationships.
                $copy = clone $model;

                // Empty out any existing relationships on the copy to avoid
                // any accidental recursion there.
                $copy->setRelations([]);

                // Set the relation on the model to the copy of itself.
                $model->setRelation($relation, $copy);
            }
        }

        return $models;
    }
}

Once you've created your custom relationship class, you need to update your model to use this custom relationship. Create a new method on your model that will use your new CustomBelongsTo relationship, and update your parent() relationship method to use this new method, instead of the base belongsTo() method.

class Product extends Model
{

    // Update the parent() relationship to use the custom belongsto relationship
    public function parent()
    {
        return $this->customBelongsTo('App\Product', 'parent_id', 'id');
    }

    // Add the method to create the CustomBelongsTo relationship. This is
    // basically a copy of the base belongsTo method, but it returns
    // a new CustomBelongsTo relationship instead of the original BelongsTo relationship
    public function customBelongsTo($related, $foreignKey = null, $otherKey = null, $relation = null)
    {
        // If no relation name was given, we will use this debug backtrace to extract
        // the calling method's name and use that as the relationship name as most
        // of the time this will be what we desire to use for the relationships.
        if (is_null($relation)) {
            list($current, $caller) = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);

            $relation = $caller['function'];
        }

        // If no foreign key was supplied, we can use a backtrace to guess the proper
        // foreign key name by using the name of the relationship function, which
        // when combined with an "_id" should conventionally match the columns.
        if (is_null($foreignKey)) {
            $foreignKey = Str::snake($relation).'_id';
        }

        $instance = new $related;

        // Once we have the foreign key names, we'll just create a new Eloquent query
        // for the related models and returns the relationship instance which will
        // actually be responsible for retrieving and hydrating every relations.
        $query = $instance->newQuery();

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

        return new CustomBelongsTo($query, $this, $foreignKey, $otherKey, $relation);
    }
}

Fair warning, none of this has been tested.

like image 124
patricus Avatar answered Sep 24 '22 10:09

patricus