3

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?
6
  • Can you show us the external scope of your Category? Commented Nov 9, 2018 at 14:27
  • @ChinLeung Updated my question. Commented Nov 9, 2018 at 14:39
  • I just tried to reproduce it locally but I've been getting the correct answer for both external true and false. Try to do the following: $test->attendedCourses()->count(), $test->attendedCourses()->external(false)->count() and $test->attendedCourses()->external(true)->count(), do the numbers add up correctly? Commented Nov 9, 2018 at 15:14
  • @ChinLeung Nope. The numbers add up correctly from the external scope. But but the $test->attendedCourses()->count() returns everything, not using the attendedCoursesInYears scope for some reason. Commented Nov 9, 2018 at 15:31
  • Ah now I understand what you mean... Well your attendedCoursesInYears is filtering the users that has taken a course in the years X-Y, but it's not filtering the courses by the year it's taken. The scope should be done on your pivot table instead. Commented Nov 9, 2018 at 15:53

1 Answer 1

3
+50

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');
Sign up to request clarification or add additional context in comments.

4 Comments

Thanks, but is this the only way? I'd like to have the attendedCourses filtered before I sum the points. Something like using a scope in my controller. This because I want the fields as columns so I can order and access them easily without too many if statements in a view.
@Bas What do you mean? They are filtered.
The result of $user->attendedCourses($years)->external(false)->sum('points') should be an attribute/column in the User model. When wanting to collect a whole list of users, I'd like to have this data as column/attribute. This so I can also re-order the list quickly. This is why I was hoping I could do it with an scope.
@Bas I'm not quite sure to understand your database structure, could you include some migration files so I can reproduce your structure in my local environment?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.