Unexpected result on relationship filtering using multiple Laravel scopes

Viewed 330

I'm using Laravel 5.6 and I'm trying to filter a relationship inside my User model. A user can attend courses, which has points bound to them.

Users can earn these points by attending the courses. This is a BelongsToMany relationship.

I'm tried creating a scope in this User model that would only include the attended courses in a range of years.

/**
 * Retrieves the courses which the user has attended
 */
public function attendedCourses()
{
    return $this->belongsToMany(Course::class, 'course_attendees');
}

/**
 * Searches the user model
 *
 * @param \Illuminate\Database\Eloquent\Builder $builder
 * @param array                                 $years
 *
 * @return \Illuminate\Database\Eloquent\Builder
 */
public function scopeAttendedCoursesInYears(Builder $builder, array $years)
{
    # Filter on the years
    $callback = function($q) use ($years) {
        foreach ($years as $year) {
            $q->year($year);
        }
    };

    return $builder->with(['attendedCourses' => $callback]);
}

In my Course model, I have a scope that filters on the year that course was in.

public function scopeYear(Builder $query, int $year)
{
    return $query->whereYear('end_date', $year);
}

With this attendedCoursesInYears scope I hoped I could then calculate the amount of points for each user by summing up the course points, using other scopes on the Course model.

public function scopeExternal(Builder $query, bool $flag = true)
{
    $categoryIsExternal = function($query) use ($flag) {
        $query->external($flag);
    };

    return $query->whereHas('category', $categoryIsExternal);
}

In my CourseCategory modal, the scope looks like this:

/**
 * Scope a query to only include external categories.
 *
 * @param \Illuminate\Database\Eloquent\Builder $query
 *
 * @param bool                                  $flag
 *
 * @return \Illuminate\Database\Eloquent\Builder
 */
public function scopeExternal(Builder $query, $flag = true)
{
    return $query->where('type', '=', $flag ? 'Extern' : 'Intern');
}

For calculating this, I tried doing something like this.

# Retrieve all the active users
$users = User::all()->attendedCoursesInYears($years);
$test = $users->find(123);

# Calculate the points
$test->attendedCourses()->external(false)->sum('points');

This however returned the total sum of all courses.

The use of a scope is the only option here, as I can see. I want to create custom attributes from these values using accessors like this. This so I could easily sort the calculated values.

/**
 * The users internal course points
 *
 * @param array $years The years to look for attended courses
 *
 * @return float
 */
public function getInternalPointsAttribute() : float
{
    return $this->attendedCourses()->external(false)->sum('points');
}

The only problem here is the year filter. I was hoping I could filter the User collection before calling the accessor like my first example.

What am I doing wrong here?

I'm currently using this workaround. This seems so bad, because I'm repeating so much code.

/**
 * @param \Illuminate\Database\Eloquent\Builder $builder
 *
 * @param array                                 $years
 *
 * @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder
 */
public function scopeWithPoints(Builder $builder, array $years = [])
{
    # Join all columns
    $builder->join('user_roles', 'users.role_id', '=', 'user_roles.id')
            ->leftJoin('course_attendees', 'users.id', '=', 'course_attendees.user_id');

    # Join the course table for the years
    $builder->leftJoin('courses', function(JoinClause $join) use ($years) {
        # Join the courses table with year filters
        $join->on('course_attendees.course_id', '=', 'courses.id');

        # Apply the filters if available
        !empty($years) and $join->whereIn(DB::raw('YEAR(courses.end_date)'), $years);
    });

    # Select the columns
    $builder->select('users.*')->groupBy('users.id');

    # Sums
    $internalPoints = 'SUM(courses.points_internal)';
    $externalPoints = 'SUM(courses.points_external)';

    # Select the points
    $builder->selectRaw('COALESCE(' . $internalPoints . ', 0) as internal_points');
    $builder->selectRaw('COALESCE(' . $externalPoints . ', 0) as external_points');
    $builder->selectRaw('COALESCE(' . $internalPoints . ' + ' . $externalPoints . ', 0) as total_points');

    # Sum up the course points
    return $builder;
}

The migration for my database structure can be found here.

Schema::create('course_attendees', function(Blueprint $table)
{
    $table->integer('id', true);
    $table->integer('user_id')->index('course_attendees_users_id_fk');
    $table->integer('course_id')->index('course_attendees_courses_id_fk');
    $table->boolean('mijnafas');
});

Schema::create('courses', function(Blueprint $table)
{
    $table->integer('id', true);
    $table->string('title');
    $table->string('subject');
    $table->string('presenter');
    $table->date('start_date')->nullable()->comment('Set to not null later');
    $table->date('end_date')->nullable();
    $table->decimal('points', 4)->nullable();
    $table->string('location');
    $table->timestamps();
});

Schema::create('users', function(Blueprint $table)
{
    $table->integer('id', true);
    $table->string('first_name')->nullable();
    $table->string('last_name')->nullable();
    $table->timestamps();
});

Schema::table('course_attendees', function(Blueprint $table)
{
    $table->foreign('course_id', 'course_attendees_courses_id_fk')->references('id')->on('courses')->onUpdate('RESTRICT')->onDelete('RESTRICT');
    $table->foreign('user_id', 'course_attendees_users_id_fk')->references('id')->on('users')->onUpdate('RESTRICT')->onDelete('RESTRICT');
});

I noticed when just calling $test->attendedCourses it retrieves the filtered collection. The problem with this is that I can't apply the scopes on this.

Questions

  • How does it come that it will not sum the filtered collection?
  • How can I make it so that it will filter this collection accordingly?
1 Answers

The problem is that your scopeAttendedCoursesInYears method in your User class is to scope out the users who took the courses in the specific years and not scope out the courses that were taken during the years.

To do what you want, you could add a parameter to your relation to filter out the pivot table instead of your users table:

public function attendedCourses(array $years = null)
{
    $query = $this->belongsToMany(Course::class, 'course_user');

    if ($years) {
        $query->whereRaw('YEAR(end_date) IN (?)', [
            'years' => implode(',', $years)
        ]);
    }

    return $query;
}

Then you could achieve your result like this:

$user = User::find(123);

$user->attendedCourses($years)->external(false)->sum('points');
Related