17

TL;DR: Need latest message from each sender.

In my Laravel application I have two tables:

Users:

  • id
  • name

Messages:

  • id
  • sender_id
  • recipient_id
  • body
  • created_at

And of course models.

User model:

public function messages() {
    return $this->hasMany('App\Message', 'recipient_id');
}

Messages model:

public function sender() {
    return $this->belongsTo('App\User', 'sender_id');
}

public function recipient() {
    return $this->belongsTo('App\User', 'recipient_id');
}

When user opens his inbox, he should see list of latest message from any other user.

So if there are messages:

id sender_id recipient_id body created_at
1, 2,        1,           hi,  2016-06-20 12:00:00
2, 2,        1,           hi,  2016-06-21 12:00:00
3, 3,        1,           hi,  2016-06-20 12:00:00
4, 3,        1,           hi,  2016-06-21 12:00:00

Then user with id 1 (recipient_id) should see only messages with id 2 and 4.

This is current solution in Users model:

return Message::whereIn('id', function($query) {
                $query->selectRaw('max(`id`)')
                ->from('messages')
                ->where('recipient_id', '=', $this->id)
                ->groupBy('sender_id');
            })->select('sender_id', 'body', 'created_at')
            ->orderBy('created_at', 'desc')
            ->get();

This is working, but I was wandering if it is possible to achieve this the Laravel way. Probably with eager loading. My Laravel skills just not enough and after several days of trying I don't have a solution.

Thanks.

Trakus Ret
  • 311
  • 3
  • 8
  • I think this solution is completely fine. What is it about this solution that bothers you exactly? – Alex Harris Jun 30 '16 at 12:41
  • Just that I thought there is a more correct and clean way to do it in Laravel. – Trakus Ret Jun 30 '16 at 12:55
  • I would be more concerned with how many queries your function is taking and how long those queries take. – Alex Harris Jun 30 '16 at 12:58
  • You can't group by if you use mysql strict as it violates SQL-92. Laravel 5.4 has strict as default – Sabine Feb 12 '17 at 18:46
  • Is there any particlar reason for using whereIn ?? – Madhab452 Feb 13 '17 at 10:26
  • Sory I wasn't attentive enough, you may use group by in conjunction with aggregates, so I find the answer of Trakus Ret the clearest. Just found one post https://softonsofa.com/tweaking-eloquent-relations-how-to-get-latest-related-model/. Wondering if it is possible to do it some how like here – Sabine Feb 13 '17 at 12:06
  • 1
    Simply \Auth::user()->messages()->orderBy('created_at', 'DESC')->first() won't work? – ad4s Feb 14 '17 at 18:18
  • If I may ask, The messages you are trying to fetch is for a particular user. right? i.e not for all users. – Oluwatobi Samuel Omisakin Feb 16 '17 at 09:27
  • Omisakin, yes it is, for particular user -recepient. He should receive a list of users (senders) with the last message and ordered by created_at/id of the last message – Sabine Feb 17 '17 at 00:47
  • I updated my answer according to above comment. Please check if it suites you. – Gayan Feb 19 '17 at 03:28
  • 2
    The solution you are considering is not functional, I think that if the user with id 1 replies to id 2 or id 3, the last message that will be displayed will be the one sent from id 2 or id 3, then in the end it will not show the last message of the conversation – Brian Aug 21 '19 at 19:37

9 Answers9

9

Taking inspiration from this post, the most efficient way to do this would be like so:

DB::table('messages AS m1')
    ->leftjoin('messages AS m2', function($join) {
        $join->on('m1.sender_id', '=', 'm2.sender_id');
        $join->on('m1.id', '<', 'm2.id')
    })->whereNull('m2.id')
    ->select('m1.sender_id', 'm1.body', 'm1.created_at')
    ->orderBy('m1.created_at', 'm1.desc')->get();

While it is not the most Laravel friendly, it is the best solution based on performance as highlighted by the post linked in this answer above

Community
  • 1
  • 1
Paras
  • 9,258
  • 31
  • 55
  • What are the benefits of this solution, comparing with the answer of question author? – Sabine Feb 13 '17 at 12:10
  • The OP's solution is exactly the same at the post's 1st query is (execution time: 1 min 17.89 secs). Whereas the query I've mentioned (post's 2nd query) is much faster (0.28 secs). This is a performance enhancement of over 275 times! – Paras Feb 13 '17 at 12:34
2

Why not simply accessing the messages, like this -

// get the authenticated user
$user = \Auth::user(); 

// find the messages for that user
return User::with('message')->find($user->id)->messages;
hashbrown
  • 3,438
  • 1
  • 19
  • 37
  • Because it will return all messages the user received. But I need only the latest one from each sender. – Trakus Ret Jun 30 '16 at 10:58
  • Ok, it was not very clear from your question before. Anyway, if u want that, an alternate solution could be to keep a "is_read" flag in your `messages` table with default value as `N`. Later, once the message is read you can update it to `Y`. This way you can only filter `is_read = 'N'` records and avoid the `max()` query – hashbrown Jun 30 '16 at 11:04
  • Actually i do have this flag (question simplified). However, it's still no use. If the user got several new messages from several other users, they all will have this flag (not read). So basically it's the same problem, only with another column in results. – Trakus Ret Jun 30 '16 at 11:11
  • Your answer still returns all the messages. What you are doing is "getting all the messages for the first user" which is not as same as "getting latest message per user" – Gayan Feb 16 '17 at 16:04
1

this may be a solution (not tested though)

User::with([
'messages' => function ($q) {
    $q->select('sender_id', 'body')->groupBy('sender_id')->orderBy('created_at', 'desc');
}
])->find(1);
sar
  • 81
  • 1
  • 4
1

I tried some similar approach and found out you only need to orderBy created_at immediately you find all the messages of the User then you can use Laravel's Collection to group them them by sender_id. So to my understanding, the following approach should work, i.e give you the last message receive by the user from the senders :

So assuming you have an Authenticated User as $user which in this context is the same as receiver with receiver_id in messages table, then:

$messages = $user->messages()
    ->orderBy('created_at', 'desc')
    ->get(['sender_id', 'body', 'created_at'])
    ->groupBy('sender_id'); //this is collections method

Then loop round the collection, and fetch the first:

$result =  new \Illuminate\Database\Eloquent\Collection ();
foreach ($messages as $message){
    $result->push($message->first()); //the first model in the collection is the latest message
}

You should end up with the result such as:

 Illuminate\Database\Eloquent\Collection {#875
     all: [
       App\Message {#872
        sender_id: "2",
        body: "hi",
        created_at: "2016-06-21 12:00:00",
      },
      App\Message {#873
        sender_id: "4",
        body: "hi",
        created_at: "2016-06-21 12:00:00",
      },
    ]

PS: A note is that I can't say how efficient this might be but one thing is for sure, is the limited number of query on the db.

Hope it helps :)

UPDATE:

I will break it down as I tried it. ~

It fetches all records of messages that belongs to user into a collection (already ordered by created_at) then, using laravel's groupBy() you have a result like the example given in that doc.

Laravel's groupBy()

This time I didnt convert to Array. Instead, its a collection Of Collections. like collection(collection(Messages))

Then you pick the first Model at each index. The parameters I already passed into the get() method ensures only those fields are present (i.e ->get(['sender_id', 'body', 'created_at']). This is is totally different from mysql groupBy(), as it does not return one row for each group rather, simply groups all records by the given identifier.

ANOTHER UPDATE

I discovered later that calling unique() method on the resulting ordered messages would actually do the job, but in this case the unique identifier would be sender_id. (ref: Laravel's unique)That is:

$messages = $user->messages()
    ->orderBy('created_at', 'desc')
    ->get(['sender_id', 'body', 'created_at'])
    ->unique('sender_id'); //unique replaces `groupBy`

The consequence is that we don't need the foreach and the groupBy we have the result here.

One other way to avoid repeatition (of the above) if this is needed in more than one place is to use Laravel's query scope i.e in Message model we can have something as this:

public function scopeLatestSendersMessages($query) 
{
     return $query->orderBy('created_at', 'desc')
         ->get(['sender_id', 'body', 'created_at'])
         ->unique('sender_id');
}

Then in the controller use:

$messages = $user->messages()->latestSendersMessages();

PS: Still not sure which one is optimally better than the other.

  • How does the laravel's collection method groupBy defines which values to return? I mean which field to return in 'body', 'created_at'? We may also use similar approach with mysql method GROUP BY, but it's not recommended in strict mysql. Are there any issues with laravel's approach? – Sabine Feb 16 '17 at 19:26
  • I will give an answer to this as update in my answer. – Oluwatobi Samuel Omisakin Feb 16 '17 at 20:30
  • 1
    I understood what you mean. You fetch all the raws from database and filter them on the back-end. I guess, when data grows this approach would be less efective. Thank you for your answer anyway + for clarity. – Sabine Feb 17 '17 at 00:56
  • @Sabine I have another update answer (you can check). Discovered this when playing around with the question. – Oluwatobi Samuel Omisakin Feb 17 '17 at 12:06
1

I like a simpler approach which is mentioned here.

In your User model additionally to existing messages() relationship, add this relationship

public function latestMessage() 
{
    return $this->hasOne(Message::class, 'recipient_id')->latest();
}

Then when you query simply query like this.

$messages = User::with('latestMessage')->get();

$messages contains latest message per user.

Edit

In order to order the result by datetime/id you could do it like this.

$messages = User::with(['latestMessage' => function($message) {
    $message->orderBy('id', 'desc');
}])->get();

$messages contains latest message per user ordered by id. Refer this answer

Community
  • 1
  • 1
Gayan
  • 3,614
  • 1
  • 27
  • 34
  • AFAIU, that doesn't order users in collection, based on the datatime/id of the last message, am I wrong? – Sabine Feb 16 '17 at 19:22
  • @Sabine thanks for pointing that out. I updated the answer with how to order the result. Please take a look. – Gayan Feb 19 '17 at 02:35
1

I found this solution in another forum, I think that is what you were looking for. I post it so it can be useful for other users

        DB::select('
            SELECT t1.*
            FROM messages AS t1
            INNER JOIN
            (
                SELECT
                    LEAST(sender_id, recipient_id) AS user_id,
                    GREATEST(sender_id, recipient_id) AS recipient_id,
                    MAX(id) AS max_id
                FROM messages
                GROUP BY
                    LEAST(sender_id, recipient_id),
                    GREATEST(sender_id, recipient_id)
            ) AS t2
                ON LEAST(t1.sender_id, t1.recipient_id) = t2.sender_id AND
                   GREATEST(t1.sender_id, t1.recipient_id) = t2.recipient_id AND
                   t1.id = t2.max_id
                WHERE t1.sender_id = ? OR t1.recipient_id = ?
            ', [auth()->guard('api')->user()->id, auth()->guard('api')->user()->id]);

original post: https://laracasts.com/discuss/channels/laravel/get-the-latest-message-of-chat-model-with-mysql-just-cannot-get-the-idea-how-to-do-this?page=1#reply=392529

Brian
  • 1,295
  • 14
  • 25
0

This is the most "Eloquent way" I have found of doing this:

In User model:

public function messages() {
    return $this->hasMany(Message::class, 'recipient_id');
}

public function latestMessagePerSender()
{
    return $this->messages()
        ->whereNotExists(function ($query) {
            $query->from('messages AS m2')
                ->whereRaw('m2.sender_id = messages.sender_id')
                ->whereRaw('m2.created_at > messages.created_at');
    });
}

Then just $user->latestMessagePerSender

alepeino
  • 9,551
  • 3
  • 28
  • 48
  • This is still a subquery and not a join. Suffers from slow performance like I've highlighted – Paras Feb 13 '17 at 18:37
  • @Paras well, that post says "Regarding performance, one solution or the other can be better, depending on the nature of your data. So you should test both queries and use the one that is better at performance given your database." Also, this solution doen't do joins, rather it uses the `EXISTS` operator, which is pretty fast since it doesn't load results, and "returns true" once it finds a match. Having said that, I have to say I didn't do benchmarks, and the OP should do his own tests anyway to find the best method for their case. – alepeino Feb 13 '17 at 19:12
  • Agreed @Alejandro. However, for most cases the join has better performance due to O(n2) is null comparisons. Your post uses the exists operator but nevertheless is a `subquery`. Check it out by adding `DB::listen` to log the queries – Paras Feb 13 '17 at 19:23
0

Maybe you can try this one:

        $user = \Auth::user(); 

        // Get the latest date
        $last_date_created = Message::latest()->first()->created_at; // also check for possible null

        // Get the date only - use to specify date range in the where section in the eloquent query
        $target_date = date('Y-m-d', strtotime( $last_date_created ) );

        // Retrieve the messages
        $latest_posts = Message::where('recipient_id', $user->id)
                               ->where('created_at', '>=', $target_date . ' 00:00:00')
                               ->where('created_at', '<=',  $target_date . ' 23:59:59')
                               ->groupBy('sender_id')
                               ->groupBy('created_at')
                               ->get();

        return $latest_posts;

This may not be very efficient since it took two queries to do the job but you will gain benefits thru code readability.

I hope it works the way it should be. I haven't tested it though but that's how I usually do this... Cheers!

Nelson Melecio
  • 1,334
  • 3
  • 12
  • 19
-1

You can try this one

Messages::where('recipient_id',**{USER_ID}**)
          ->group_by('sender_id')
          ->order_by('id','desc')
          ->get();
Tonmoy Nandy
  • 371
  • 4
  • 9
  • This doesn't work because it groups first by sender, giving the message with lowest id for each sender (although I wouldn't trust this behavior is consistent), and then it orders on that result. That's why more complex solutions exist: http://stackoverflow.com/questions/1313120/retrieving-the-last-record-in-each-group – alepeino Feb 13 '17 at 14:59