framework: Constraints on eager-loaded hasMany doesn't retrieve data correctly

  • Laravel Version: 5.2.x
  • PHP Version: 7
  • Database Driver & Version: MySQL 5.7

Description:

Constraint on eager-load only retrieves 1 record, when ->take(10) is specified.

Steps To Reproduce:

  • Each User model has their instagram data updated daily, and inserted into an AccountData model: User::hasMany(AccountData::class);
  • User has at least 400 sibling update records inserted. Going through every user to perform updates kills memory, some have up to 2000. Only the last/previous record is needed, so the hasMany relation is constrained to take only the last 10 or so (to be safe).

Example code, before constraint:

User::with(['account_data'])->chunk(10, function ($users) {
  foreach ($users as $user) {
    // do something with $user->account_data->last(). 
   // could have 400+ account_data
  }
}

This will produce 400 records in the hasMany relation.

Then with optimized version:

User::with([
  'account_data' => function($query) {
    $query->orderBy('created_at', 'DESC')->take(10);
  }])->chunk(10, function ($users) {
  foreach ($users as $user) {
    // zero records, 1 record, or the first ever record
    // account_data->count() is 1
  }
}

This is expected to eager load the previous 10 records for each user, but only ever returns a) no records, or b) the first ever record. The result is unpredictable.

When manually pulling the user’s relation count, the results are consistent:

// test here is:
$user->account_data->count(); // eager-loaded
$user->account_data()->count(); // go to db
Eager-loaded count is 1 data records.
Forced count from DB is 3 records.

Eager-loaded count is 0 data records.
Forced count from DB is 0 records.

Eager-loaded count is 0 data records.
Forced count from DB is 0 records.

Eager-loaded count is 4 data records.
Forced count from DB is 552 records.

Eager-loaded count is 1 data records.
Forced count from DB is 3 records.

Eager-loaded count is 1 data records.
Forced count from DB is 29 records.

Eager-loaded count is 0 data records.
Forced count from DB is 0 records.

Edit: SQL generated by DB::getQueryLog() is:

array (
  'query' => 'select * from "account_data" where "account_data"."user_id" in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) and "account_data"."deleted_at" is null order by "created_at" desc limit 10',
  'bindings' => 
  array (
    0 => 128,
    1 => 115,
    2 => 116,
    3 => 48,
    4 => 129,
    5 => 130,
    6 => 69,
    7 => 125,
    8 => 66,
    9 => 120,
  ),
  'time' => 39.350000000000001,
),

Say there are 1000 users being chunked - appears that the eager load constraint is being applied to the child records, rather than for each user individually, i.e. to gets the last 10 records from the hasMany relation for each parent item.

Is this a case of me not applying the constraint correctly?

About this issue

  • Original URL
  • State: closed
  • Created 8 years ago
  • Reactions: 1
  • Comments: 20 (13 by maintainers)

Commits related to this issue

Most upvoted comments

@giovannipds Your issue is not related to this thread.

It’s not possible to use a relationship with an orWhere() like this.

@azcoppen @themsaid I’ve updated the modified query builder to handle the belongToMany relationship. Not sure if this takes care of the remaining relationships or not… just using it for my own use case (GraphQL). I’m also not a fan of using the ReflectionMethod, but since the methods I need access to are protected, it seemed like the lesser of two evils (rather than refactoring the BelongToMany relationship).

If I can find some time this weekend, I’ll create some tests and check out the other relationships to see if everything still works and possibly submit a PR. Not sure what to do about SQLite in this case though since it won’t accept the supplied query.

Usage

$users = User::all();

$users->fetch(['posts' => function ($q, $user) {
    $q->take(2);
}]);

Collection Macro

Collection::macro('fetch', function ($relations) {
    if (count($this->items) > 0) {
        if (is_string($relations)) {
            $relations = [$relations];
        }

        $query = $this->first()->newQuery()->with($relations);
        $this->items = app(QueryBuilder::class)->eagerLoadRelations($query, $this->items);
    }
    return $this;
});

Updated Query Builder

use Closure;
use ReflectionMethod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\Model;

class QueryBuilder
{
    /**
     * Eager load relationships on collection.
     *
     * @param  Builder $builder
     * @param  array   $models
     * @return array
     */
    public function eagerLoadRelations(Builder $builder, array $models)
    {
        foreach ($builder->getEagerLoads() as $name => $constraints) {
            if (strpos($name, '.') === false) {
                $models = $this->loadRelation($builder, $models, $name, $constraints);
            }
        }

        return $models;
    }

    /**
     * Eagerly load the relationship on a set of models.
     *
     * @param  Builder  $builder
     * @param  array    $models
     * @param  string   $name
     * @param  Closure  $constraints
     * @return array
     */
    protected function loadRelation(Builder $builder, array $models, $name, Closure $constraints)
    {
        $relation = $builder->getRelation($name);
        $queries = $this->getQueries($builder, $models, $name, $constraints);
        $related = $queries->first()->getModel();

        $bindings = $queries->map(function ($query) {
            return $query->getBindings();
        })->collapse()->toArray();

        $sql = $queries->map(function ($query) {
            return '(' . $query->toSql() . ')';
        })->implode(' UNION ALL ');

        $table = $related->getTable();
        $results = \DB::select("SELECT `{$table}`.* FROM ({$sql}) AS `{$table}`", $bindings);
        $hydrated = $this->hydrate($related, $relation, $results);

        return $relation->match($models, $related->newCollection($hydrated), $name);
    }

    /**
     * Get queries to fetch relationships.
     *
     * @param  Builder  $builder
     * @param  array    $models
     * @param  string   $name
     * @param  Closure  $constraints
     * @return array
     */
    protected function getQueries(Builder $builder, array $models, $name, Closure $constraints)
    {
        return collect($models)->map(function ($model) use ($builder, $name, $constraints) {
            $relation = $builder->getRelation($name);

            $relation->addEagerConstraints([$model]);

            call_user_func_array($constraints, [$relation, $model]);

            if (method_exists($relation, 'getSelectColumns')) {
                $r = new ReflectionMethod(get_class($relation), 'getSelectColumns');
                $r->setAccessible(true);
                // Not sure if this is absolutely correct...
                $select = $r->invoke($relation, ['*']);
                $relation->addSelect($select);
            }

            $relation->initRelation([$model], $name);

            return $relation;
        });
    }

    /**
     * Hydrate related models.
     *
     * @param  Model    $related
     * @param  Relation $relation
     * @param  array    $results
     * @return array
     */
    protected function hydrate(Model $related, Relation $relation, array $results)
    {
        $models = $related->hydrate($results, $related->getConnectionName())->all();

        if (count($models) > 0 && method_exists($relation, 'hydratePivotRelation')) {
            $r = new ReflectionMethod(get_class($relation), 'hydratePivotRelation');
            $r->setAccessible(true);
            $r->invoke($relation, $models);
        }

        return $models;
    }
}

@derekmd Naturally, I think that’s a good idea. Would you write it?

We could also throw an exception (that links to the documentation) if someone tries to apply a limit to their relationship when eager loading.

Duplicate issues:

As noted by @staudenmeir, his pull request was declined by Taylor due to the maintenance burden for a solution not supported by all databases that Laravel projects run.

Since this problem has come up so much and it can now be fixed by a third-party package, maybe the best solution is to update https://laravel.com/docs/5.7/eloquent-relationships#eager-loading under heading “Constraining Eager Loads” to clearly communicate that limit() and take() aren’t supported unless that package is installed and MySQL, etc. are the most recent version?

I released the rejected PR as a package: https://github.com/staudenmeir/eloquent-eager-limit

OMG… Why this issue has not been fixed yet??
It has been 2 years…