Skip to content

5.7.20 Breaking Change to Relationships #27168

Closed
@JeremyJass

Description

@JeremyJass
  • Laravel Version: 5.7.20
  • PHP Version: 7.2.8
  • Database Driver & Version: MySQL 5.6.8

Description:

The 5.7.20 update included PR#26992 which introduced a breaking change to our Laravel application. In an attempt to eliminate unnecessary queries, it creates eager loading situations where a query that should (or at very least historically has) run is inhibited.

Since this is a breaking change on a point release, recommend rolling back these changes and if still desired include them as a breaking change in the next major release. I've also included a possible fix, but verifying this fixes our issue without creating any further problems seems more dangerous then just rolling back.

Possible Fix:

In the modified relationship files, instead of assuming the query is unnecessary just because $this->child->{$this->foreignKey} evaluates to null, additionally check if there are any attributes at all. The absence of any attributes indicates the Model has not yet been inflated, and the requested query may still be valid.

\Illuminate\Database\Eloquent\Relations\BelongsTo.php : 78

if (count($this->child->getAttributes()) != 0 && is_null($this->child->{$this->foreignKey})) {
    return $this->getDefaultFor($this->parent);
}

Steps To Reproduce:

A bit lengthy, but was able to minimally replicate our issue using the following 3 Model classes, and a migration listed below.

To keep things simple this is a bit contrived, but Users subscribe to Organizations, and Organizations belong to a Location. What we are trying to do is establish a conditional relationship on the Organizations class that allows us to retrieve all subscribed Users that meet some alertable criteria that is dependent on the Location.

Changed in 5.7.20, when the alertable method in the Organization class is called, $timezone = $this->location->timezone_id; throws a Trying to get property 'timezone_id' of non-object exception. This is because, $this->child->{$this->foreignKey} from BelongsTo.php (line 78) evaluates to null.

The assumption is that if $this->child->{$this->foreignKey} (in BelongsTo) is null, no related Model exists and the query can be avoided. However in our code since we are eager loading and the Model isn't inflated yet, this check fails even though we need the query to run.

Although our problem was only with the BelongsTo relationship, similar changes were made to the other relationship files (PR#26992) and they are likely impacted as well.

Target Action (put in a Command, Controller, or tinker)

// Works in 5.7.19, broken in 5.7.20
$orgs_with_alertable_users = \App\Organization::whereHas('alertable')->with('alertable')->get();

User.php

<?php
namespace App;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $table = 'test_users';
    protected $fillable = [
        'organization_id'
    ];
}

Location.php

<?php
namespace App;
use Illuminate\Database\Eloquent\Model;

class Location extends Model
{
    protected $table = 'test_locations';
    protected $fillable = [
        'timezone_id'
    ];
}

Organization.php

<?php
namespace App;
use App\User;
use App\Location;
use Illuminate\Database\Eloquent\Model;

class Organization extends Model
{
    protected $table = 'test_organizations';
    protected $fillable = [
        'location_id'
    ];

    /*
     * This Organization belongs to a Location.
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function location()
    {
        return $this->belongsTo(Location::class, 'location_id', 'id');
    }

    /*
     * Users subscribed to this Organization.
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function subscribers()
    {
        return $this->hasMany(User::class, 'organization_id', 'id');
    }

    /*
     * Users that meet some criteria that is dependent on their Location.
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function alertable()
    {
        // Worked in 5.7.19, broken in 5.7.20
        $timezone = $this->location->timezone_id;

        // Alternative approach that works in 5.7.20, but doesn't leverage query caching
        // $timezone = $this->location()->first()->timezone_id;

        // construct Location sensitive parameters
        $start_of_shift = $this->shiftStart(now($timezone));
        $end_of_shift = $start_of_shift->copy()->addHours(8);

        return $this->subscribers()
            ->whereBetween('created_at', [$start_of_shift, $end_of_shift]);
    }

    /*
     * Shift Calcuation Helper
     * @param  \Carbon\Carbon $date
     * @return \Carbon\Carbon
     */
    public static function shiftStart($date)
    {
        // Placeholder dumb logic.
        return $date->copy()->subHours(2);
    }
}

Migration File (includes seeding of dummy records)

<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateTestTables extends Migration
{
    public function up()
    {
        Schema::create('test_users', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->integer('organization_id');
        });

        Schema::create('test_locations', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->string('timezone_id');
        });

        Schema::create('test_organizations', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->integer('location_id');
        });

        // Seed dummy records
        $location = \App\Location::create([
            'timezone_id' => 'America/New_York'
        ]);
        $org = \App\Organization::create([
            'location_id' => $location->id
        ]);
        \App\User::create([
            'organization_id' => $org->id
        ]);
    }

    public function down()
    {
        Schema::dropIfExists('test_organizations');
        Schema::dropIfExists('test_locations');
        Schema::dropIfExists('test_users');
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions