1

I have two models, User and Training, with Many to many relationship between them. I'm using the Laravel Datatables package to display a table of all the users. This is how the data controller method (which retrieves the query results and creates a Datatables table) looks like:

public function getData()
{
    $users = User::select(array('users.id', 'users.full_name', 'users.email', 'users.business_unit', 'users.position_id'))
        ->where('users.is_active', '=', 1);

    return \Datatables::of($users)
        ->remove_column('id')
        ->make();
}

How can I add a column to the created table which displays the total number of relations for each user (that is, how many Trainings does each User have)?

miken32
  • 42,008
  • 16
  • 111
  • 154
Томица Кораћ
  • 2,542
  • 7
  • 35
  • 57

2 Answers2

12

The brute force way would be to try a User::selectRaw(...) which has a built in subquery to get the count of trainings for the user and expose it as a field.

However, there is a more built-in way to do this. You can eager load the relationship (to avoid the n+1 queries), and use the DataTables add_column method to add in the count. Assuming your relationship is named trainings:

public function getData() {
    $users = User::with('trainings')->select(array('users.id', 'users.full_name', 'users.email', 'users.business_unit', 'users.position_id'))
        ->where('users.is_active', '=', 1);

    return \Datatables::of($users)
        ->add_column('trainings', function($user) {
            return $user->trainings->count();
        })
        ->remove_column('id')
        ->make();
}

The name of the column in add_column should be the same name as the loaded relationship. If you use a different name for some reason, then you need to make sure to remove the relationship column so it is removed from the data array. For example:

    return \Datatables::of($users)
        ->add_column('trainings_count', function($user) {
            return $user->trainings->count();
        })
        ->remove_column('id')
        ->remove_column('trainings')
        ->make();

Edit

Unfortunately, if you want to order on the count field, you will need the brute force method. The package does its ordering by calling ->orderBy() on the Builder object passed to the of() method, so the query itself needs the field on which to order.

However, even though you'll need to do some raw SQL, it can be made a little cleaner. You can add a model scope that will add in the count of the relations. For example, add the following method to your User model:

Note: the following function only works for hasOne/hasMany relationships. Please refer to Edit 2 below for an updated function to work on all relationships.

public function scopeSelectRelatedCount($query, $relationName, $fieldName = null)
{
    $relation = $this->$relationName(); // ex: $this->trainings()
    $related = $relation->getRelated(); // ex: Training
    $parentKey = $relation->getQualifiedParentKeyName(); // ex: users.id
    $relatedKey = $relation->getForeignKey(); // ex: trainings.user_id
    $fieldName = $fieldName ?: $relationName; // ex: trainings

    // build the query to get the count of the related records
    // ex: select count(*) from trainings where trainings.id = users.id
    $subQuery = $related->select(DB::raw('count(*)'))->whereRaw($relatedKey . ' = ' . $parentKey);

    // build the select text to add to the query
    // ex: (select count(*) from trainings where trainings.id = users.id) as trainings
    $select = '(' . $subQuery->toSql() . ') as ' . $fieldName;

    // add the select to the query
    return $query->addSelect(DB::raw($select));
}

With that scope added to your User model, your getData function becomes:

public function getData() {
    $users = User::select(array('users.id', 'users.full_name', 'users.email', 'users.business_unit', 'users.position_id'))
        ->selectRelatedCount('trainings')
        ->where('users.is_active', '=', 1);

    return \Datatables::of($users)
        ->remove_column('id')
        ->make();
}

If you wanted the count field to have a different name, you can pass the name of the field in as the second parameter to the selectRelatedCount scope (e.g. selectRelatedCount('trainings', 'training_count')).

Edit 2

There are a couple issues with the scopeSelectRelatedCount() method described above.

First, the call to $relation->getQualifiedParentKeyName() will only work on hasOne/hasMany relations. This is the only relationship where that method is defined as public. All the other relationships define this method as protected. Therefore, using this scope with a relationship that is not hasOne/hasMany throws an Illuminate\Database\Query\Builder::getQualifiedParentKeyName() exception.

Second, the count SQL generated is not correct for all relationships. Again, it would work fine for hasOne/hasMany, but the manual SQL generated would not work at all for a many to many relationship (belongsToMany).

I did, however, find a solution to both issues. After looking through the relationship code to determine the reason for the exception, I found Laravel already provides a public method to generate the count SQL for a relationship: getRelationCountQuery(). The updated scope method that should work for all relationships is:

public function scopeSelectRelatedCount($query, $relationName, $fieldName = null)
{
    $relation = $this->$relationName(); // ex: $this->trainings()
    $related = $relation->getRelated(); // ex: Training
    $fieldName = $fieldName ?: $relationName; // ex: trainings

    // build the query to get the count of the related records
    // ex: select count(*) from trainings where trainings.id = users.id
    $subQuery = $relation->getRelationCountQuery($related->newQuery(), $query);

    // build the select text to add to the query
    // ex: (select count(*) from trainings where trainings.id = users.id) as trainings
    $select = '(' . $subQuery->toSql() . ') as ' . $fieldName;

    // add the select to the query
    return $query->addSelect(DB::raw($select));
}

Edit 3

This update allows you to pass a closure to the scope that will modify the count subquery that is added to the select fields.

public function scopeSelectRelatedCount($query, $relationName, $fieldName = null, $callback = null)
{
    $relation = $this->$relationName(); // ex: $this->trainings()
    $related = $relation->getRelated(); // ex: Training
    $fieldName = $fieldName ?: $relationName; // ex: trainings

    // start a new query for the count statement
    $countQuery = $related->newQuery();

    // if a callback closure was given, call it with the count query and relationship
    if ($callback instanceof Closure) {
        call_user_func($callback, $countQuery, $relation);
    }

    // build the query to get the count of the related records
    // ex: select count(*) from trainings where trainings.id = users.id
    $subQuery = $relation->getRelationCountQuery($countQuery, $query);

    // build the select text to add to the query
    // ex: (select count(*) from trainings where trainings.id = users.id) as trainings
    $select = '(' . $subQuery->toSql() . ') as ' . $fieldName;

    $queryBindings = $query->getBindings();
    $countBindings = $countQuery->getBindings();

    // if the new count query has parameter bindings, they need to be spliced
    // into the existing query bindings in the correct spot
    if (!empty($countBindings)) {
        // if the current query has no bindings, just set the current bindings
        // to the bindings for the count query
        if (empty($queryBindings)) {
            $queryBindings = $countBindings;
        } else {
            // the new count query bindings must be placed directly after any
            // existing bindings for the select fields
            $fields = implode(',', $query->getQuery()->columns);
            $numFieldParams = 0;
            // shortcut the regex if no ? at all in fields
            if (strpos($fields, '?') !== false) {
                // count the number of unquoted parameters (?) in the field list
                $paramRegex = '/(?:(["\'])(?:\\\.|[^\1])*\1|\\\.|[^\?])+/';
                $numFieldParams = preg_match_all($paramRegex, $fields) - 1;
            }
            // splice into the current query bindings the bindings needed for the count subquery
            array_splice($queryBindings, $numFieldParams, 0, $countBindings);
        }
    }

    // add the select to the query and update the bindings
    return $query->addSelect(DB::raw($select))->setBindings($queryBindings);
}

With the updated scope, you can use the closure to modify the count query:

public function getData() {
    $users = User::select(array('users.id', 'users.full_name', 'users.email', 'users.business_unit', 'users.position_id'))
        ->selectRelatedCount('trainings', 'trainings', function($query, $relation) {
            return $query
                ->where($relation->getTable().'.is_creator', false)
                ->where($relation->getTable().'.is_speaker', false)
                ->where($relation->getTable().'.was_absent', false);
        })
        ->where('users.is_active', '=', 1);

    return \Datatables::of($users)
        ->remove_column('id')
        ->make();
}

Note: as of this writing, the bllim/laravel4-datatables-package datatables package has an issue with parameter bindings in subqueries in the select fields. The data will be returned correctly, but the counts will not ("Showing 0 to 0 of 0 entries"). I have detailed the issue here. The two options are to manually update the datatables package with the code provided in that issue, or to not use parameter binding inside the count subquery. Use whereRaw to avoid parameter binding.

patricus
  • 59,488
  • 15
  • 143
  • 145
  • patricius this works. Awesome. Thanks! By the way, I tried `selectRaw()`, but that messed up table sorting and search for some reason. – Томица Кораћ Jan 12 '15 at 22:03
  • patricius do you know by any chance why isn't table sorting working for the column I added this way? My table has 6 columns and sorting works for all of them except the one I just added. – Томица Кораћ Jan 13 '15 at 09:58
  • 1
    @ТомицаКораћ I have updated the answer based on your comment/question. Basically, the field needs to be part of the select statement to be able to order it. The update has a solution. – patricus Jan 13 '15 at 20:54
  • Huh, for some reason, this gave me a bad method exception: `Call to undefined method Illuminate\Database\Query\Builder::getQualifiedParentKeyName() ` – Томица Кораћ Jan 14 '15 at 06:48
  • 1
    @ТомицаКораћ You may need to post some code. That method is on the Illuminate/Database/Eloquent/Relations/Relation object. I don't know if you copy/pasted my code or not, but if you accidentally had `$related->getQualifiedParentKeyName()` instead of `$relation->getQualifiedParentKeyName()`, you would get that error (I think). `$related` is the model, `$relation` is the relationship object. – patricus Jan 14 '15 at 06:55
  • Sorry patricius, I was a bit busy for a couple of days. Well here's my `User` model method: http://laravel.io/bin/roYeL and here's the UsersController` getData method: http://laravel.io/bin/E38wB – Томица Кораћ Jan 16 '15 at 09:55
  • 1
    @ТомицаКораћ What does the User `trainings()` method look like? You may need to start another question to keep all this organized. – patricus Jan 16 '15 at 20:18
  • patricius, the `trainings()` method is fairly simple: http://laravel.io/bin/42X0X . Do you think there could be something wrong in my relation here? – Томица Кораћ Jan 17 '15 at 06:26
  • 1
    @ТомицаКораћ Nothing wrong with the relationship, but it did help me figure out the error. I have updated my answer with the details (Edit 2). In addition to that, I wanted to mention in regards to your `getData` method, if you eager load the 'trainings' relationship, you will need to use the second parameter of the `selectRelatedCount()` scope. Otherwise, your 'trainings' relationship data will overwrite your 'trainings' count, and then the `remove_column('trainings')` will remove that, as well. – patricus Jan 18 '15 at 06:11
  • patricius, this works now, thanks. I just have one last question. In my `training_user` table I have a few additional fields, like `is_creator`, `is_speaker`, `was_absent`. How much more work would it take to get the number of relations where the pivot field is true or false, for example return count of all the user's trainings where `was_absent` `=` `true`? – Томица Кораћ Jan 18 '15 at 14:02
  • 1
    @ТомицаКораћ It shouldn't be that bad. Basically, add a third parameter (defaulted to null) that accepts a Closure that expects one parameter: the query to generate the count. The closure will then be used to add on the where conditions for the count statement. I don't have access to my dev environment right now, but I'll post updated code tomorrow. – patricus Jan 19 '15 at 07:25
  • patricius, you are one hell of a good guy. I can't thank you enough for your time and patience. Also, the knowledge you've passed on is extremely valuable to me learning Laravel. I'm going to make this question a bounty and award you with my fake internet points :) – Томица Кораћ Jan 22 '15 at 07:54
  • Haha. Well, I definitely appreciate that. It was a pretty entertaining solution, and I'm glad I got to help you out. It even led to a bugfix in L4.2. :) – patricus Jan 23 '15 at 07:19
1

I would setup your DB tables and Eloquent models using the conventions provided at http://laravel.com/docs/4.2/eloquent. In your example you would have three tables.

  • trainings
  • training_user
  • users

Your models would look something like this.

class Training {

    public function users() {
        return $this->belongsToMany('User');    
    }

}

class User {

    public function trainings() {
        return $this->belongsToMany('Training');    
    }

}

You can then use Eloquent to get a list of users and eager load their trainings.

// Get all users and eager load their trainings
$users = User::with('trainings')->get();

If you want to count the number of trainings per user you can simply iterate over $users and count the size of the trainings array.

foreach ( $users as $v ) {
    $numberOfTrainings = sizeof($v->trainings); 
}

Or you can simply do it in pure SQL. Note that my example below assumes you follow Laravel's conventions for naming tables and columns.

SELECT 
    u.*, COUNT(p.user_id) AS number_of_trainings 
FROM 
    users u 
JOIN 
    training_user p ON u.id = p.user_id 
GROUP BY 
    u.id

Now that you have a couple of ways to count the number of relations, you can use whatever method you like to store that value somewhere. Just remember that if you store that number as a value in the user table you'll need to update it every time a user creates/updates/deletes a training (and vice versa!).

Dave
  • 3,658
  • 1
  • 16
  • 9
  • Dave, thanks for your answer, but that wasn't quite my question. My models are already set up the way you described, and I do know how to count the relations. But my problem is that it doesn't work within `Datatables` class. How can I add the count to the `Datatales` table? – Томица Кораћ Jan 12 '15 at 21:15