Skip to content

Commit 1423e46

Browse files
committed
Add cursor-based pagination for messages
Cursor pagination is more suitable for real-time chat applications as it avoids duplicate messages when new messages arrive between page loads. This adds a new endpoint alongside the existing offset-based pagination to maintain backwards compatibility. New endpoint: GET /conversations/{id}/messages-cursor Closes #324
1 parent 810eca3 commit 1423e46

File tree

7 files changed

+312
-0
lines changed

7 files changed

+312
-0
lines changed

src/Http/Controllers/ConversationMessageController.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Musonza\Chat\Http\Requests\ClearConversation;
77
use Musonza\Chat\Http\Requests\DeleteMessage;
88
use Musonza\Chat\Http\Requests\GetParticipantMessages;
9+
use Musonza\Chat\Http\Requests\GetParticipantMessagesWithCursor;
910
use Musonza\Chat\Http\Requests\StoreMessage;
1011

1112
class ConversationMessageController extends Controller
@@ -48,6 +49,27 @@ public function index(GetParticipantMessages $request, $conversationId)
4849
return response($message);
4950
}
5051

52+
/**
53+
* Get messages using cursor-based pagination.
54+
*
55+
* Cursor pagination is more suitable for real-time chat applications
56+
* as it avoids duplicate messages when new messages arrive between page loads.
57+
*/
58+
public function indexWithCursor(GetParticipantMessagesWithCursor $request, $conversationId)
59+
{
60+
$conversation = Chat::conversations()->getById($conversationId);
61+
$messages = Chat::conversation($conversation)
62+
->setParticipant($request->getParticipant())
63+
->setCursorPaginationParams($request->getCursorPaginationParams())
64+
->getMessagesWithCursor();
65+
66+
if ($this->messageTransformer) {
67+
return fractal($messages, $this->messageTransformer)->respond();
68+
}
69+
70+
return response($messages);
71+
}
72+
5173
public function show(GetParticipantMessages $request, $conversationId, $messageId)
5274
{
5375
$message = Chat::messages()->getById($messageId);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Musonza\Chat\Http\Requests;
4+
5+
use Musonza\Chat\ValueObjects\Pagination;
6+
7+
class GetParticipantMessagesWithCursor extends BaseRequest
8+
{
9+
/**
10+
* @var Pagination
11+
*/
12+
private $pagination;
13+
14+
public function __construct(Pagination $pagination)
15+
{
16+
parent::__construct();
17+
$this->pagination = $pagination;
18+
}
19+
20+
public function authorized()
21+
{
22+
return true;
23+
}
24+
25+
public function rules()
26+
{
27+
return [
28+
'participant_id' => 'required',
29+
'participant_type' => 'required',
30+
'perPage' => 'integer',
31+
'sorting' => 'string|in:asc,desc',
32+
'columns' => 'array',
33+
'cursor' => 'string|nullable',
34+
'cursorName' => 'string',
35+
];
36+
}
37+
38+
public function getCursorPaginationParams()
39+
{
40+
return [
41+
'perPage' => $this->perPage ?? $this->pagination->getPerPage(),
42+
'sorting' => $this->sorting ?? $this->pagination->getSorting(),
43+
'columns' => $this->columns ?? $this->pagination->getColumns(),
44+
'cursor' => $this->cursor ?? null,
45+
'cursorName' => $this->cursorName ?? 'cursor',
46+
];
47+
}
48+
}

src/Http/routes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
->name('conversations.messages.store');
3333
Route::get('/conversations/{id}/messages', 'ConversationMessageController@index')
3434
->name('conversations.messages.index');
35+
Route::get('/conversations/{id}/messages-cursor', 'ConversationMessageController@indexWithCursor')
36+
->name('conversations.messages.index.cursor');
3537
Route::get('/conversations/{id}/messages/{message_id}', 'ConversationMessageController@show')
3638
->name('conversations.messages.show');
3739
Route::delete('/conversations/{id}/messages', 'ConversationMessageController@deleteAll')

src/Models/Conversation.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Musonza\Chat\Models;
44

55
use Chat;
6+
use Illuminate\Contracts\Pagination\CursorPaginator;
67
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
78
use Illuminate\Database\Eloquent\Model;
89
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -89,6 +90,21 @@ public function getMessages(Model $participant, $paginationParams, $deleted = fa
8990
return $this->getConversationMessages($participant, $paginationParams, $deleted);
9091
}
9192

93+
/**
94+
* Get messages for a conversation using cursor-based pagination.
95+
*
96+
* Cursor pagination is more suitable for real-time chat applications
97+
* as it avoids duplicate messages when new messages arrive between page loads.
98+
*
99+
* @param array $paginationParams
100+
* @param bool $deleted
101+
* @return CursorPaginator
102+
*/
103+
public function getMessagesWithCursor(Model $participant, $paginationParams, $deleted = false)
104+
{
105+
return $this->getConversationMessagesWithCursor($participant, $paginationParams, $deleted);
106+
}
107+
92108
public function getParticipantConversations($participant, array $options)
93109
{
94110
return $this->getConversationsList($participant, $options);
@@ -324,6 +340,37 @@ private function getConversationMessages(Model $participant, $paginationParams,
324340
return $messages;
325341
}
326342

343+
/**
344+
* Get messages in conversation using cursor-based pagination.
345+
*
346+
* @return CursorPaginator
347+
*/
348+
private function getConversationMessagesWithCursor(Model $participant, $paginationParams, $deleted)
349+
{
350+
$messages = $this->messages()
351+
->join($this->tablePrefix . 'message_notifications', $this->tablePrefix . 'message_notifications.message_id', '=', $this->tablePrefix . 'messages.id')
352+
->where($this->tablePrefix . 'message_notifications.messageable_type', $participant->getMorphClass())
353+
->where($this->tablePrefix . 'message_notifications.messageable_id', $participant->getKey());
354+
$messages = $deleted ? $messages->whereNotNull($this->tablePrefix . 'message_notifications.deleted_at') : $messages->whereNull($this->tablePrefix . 'message_notifications.deleted_at');
355+
$messages = $messages->orderBy($this->tablePrefix . 'messages.id', $paginationParams['sorting'])
356+
->cursorPaginate(
357+
$paginationParams['perPage'],
358+
[
359+
$this->tablePrefix . 'message_notifications.updated_at as read_at',
360+
$this->tablePrefix . 'message_notifications.deleted_at as deleted_at',
361+
$this->tablePrefix . 'message_notifications.messageable_id',
362+
$this->tablePrefix . 'message_notifications.id as notification_id',
363+
$this->tablePrefix . 'message_notifications.is_seen',
364+
$this->tablePrefix . 'message_notifications.is_sender',
365+
$this->tablePrefix . 'messages.*',
366+
],
367+
$paginationParams['cursorName'] ?? 'cursor',
368+
$paginationParams['cursor'] ?? null
369+
);
370+
371+
return $messages;
372+
}
373+
327374
/**
328375
* @return mixed
329376
*/

src/Services/ConversationService.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ public function getMessages()
5858
return $this->conversation->getMessages($this->participant, $this->getPaginationParams(), $this->deleted);
5959
}
6060

61+
/**
62+
* Get messages in a conversation using cursor-based pagination.
63+
*
64+
* Cursor pagination is more suitable for real-time chat applications
65+
* as it avoids duplicate messages when new messages arrive between page loads.
66+
*
67+
* @return \Illuminate\Contracts\Pagination\CursorPaginator
68+
*/
69+
public function getMessagesWithCursor()
70+
{
71+
return $this->conversation->getMessagesWithCursor($this->participant, $this->getCursorPaginationParams(), $this->deleted);
72+
}
73+
6174
/**
6275
* Clears conversation.
6376
*/

src/Traits/Paginates.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ trait Paginates
1616

1717
protected $deleted = false;
1818

19+
protected $cursor = null;
20+
21+
protected $cursorName = 'cursor';
22+
1923
/**
2024
* Set the limit.
2125
*
@@ -75,4 +79,44 @@ public function getPaginationParams()
7579
'pageName' => $this->pageName,
7680
];
7781
}
82+
83+
public function getCursorPaginationParams()
84+
{
85+
return [
86+
'perPage' => $this->perPage,
87+
'sorting' => $this->sorting,
88+
'columns' => $this->columns,
89+
'cursor' => $this->cursor,
90+
'cursorName' => $this->cursorName,
91+
];
92+
}
93+
94+
/**
95+
* Set the cursor for cursor-based pagination.
96+
*
97+
* @param string|null $cursor
98+
* @return $this
99+
*/
100+
public function cursor($cursor)
101+
{
102+
$this->cursor = $cursor;
103+
104+
return $this;
105+
}
106+
107+
/**
108+
* Set cursor pagination parameters.
109+
*
110+
* @return $this
111+
*/
112+
public function setCursorPaginationParams($params)
113+
{
114+
foreach ($params as $key => $value) {
115+
if (property_exists($this, $key)) {
116+
$this->{$key} = $value;
117+
}
118+
}
119+
120+
return $this;
121+
}
78122
}

tests/Feature/ConversationMessageControllerTest.php

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,140 @@ public function test_destroy()
127127
->assertSuccessful();
128128
$this->assertCount(2, Chat::conversation($conversation)->setParticipant($userModel)->getMessages());
129129
}
130+
131+
public function test_index_with_cursor()
132+
{
133+
$conversation = factory(Conversation::class)->create();
134+
$userModel = factory(User::class)->create();
135+
$clientModel = factory(Client::class)->create();
136+
137+
Chat::conversation($conversation)->addParticipants([$userModel, $clientModel]);
138+
Chat::message('message 1')->from($userModel)->to($conversation)->send();
139+
Chat::message('message 2')->from($clientModel)->to($conversation)->send();
140+
Chat::message('message 3')->from($userModel)->to($conversation)->send();
141+
142+
$parameters = [
143+
$conversation->getKey(),
144+
'participant_id' => $userModel->getKey(),
145+
'participant_type' => $userModel->getMorphClass(),
146+
'perPage' => 2,
147+
'sorting' => 'asc',
148+
];
149+
150+
$response = $this->getJson(route('conversations.messages.index.cursor', $parameters))
151+
->assertStatus(200)
152+
->assertJsonStructure([
153+
'data' => [
154+
[
155+
'sender',
156+
'body',
157+
],
158+
],
159+
'next_cursor',
160+
'next_page_url',
161+
'path',
162+
'per_page',
163+
'prev_cursor',
164+
'prev_page_url',
165+
]);
166+
167+
$this->assertCount(2, $response->json('data'));
168+
}
169+
170+
public function test_index_with_cursor_pagination_navigation()
171+
{
172+
$conversation = factory(Conversation::class)->create();
173+
$userModel = factory(User::class)->create();
174+
$clientModel = factory(Client::class)->create();
175+
176+
Chat::conversation($conversation)->addParticipants([$userModel, $clientModel]);
177+
178+
// Create 5 messages
179+
Chat::message('message 1')->from($userModel)->to($conversation)->send();
180+
Chat::message('message 2')->from($clientModel)->to($conversation)->send();
181+
Chat::message('message 3')->from($userModel)->to($conversation)->send();
182+
Chat::message('message 4')->from($clientModel)->to($conversation)->send();
183+
Chat::message('message 5')->from($userModel)->to($conversation)->send();
184+
185+
// Get first page with 2 items
186+
$parameters = [
187+
$conversation->getKey(),
188+
'participant_id' => $userModel->getKey(),
189+
'participant_type' => $userModel->getMorphClass(),
190+
'perPage' => 2,
191+
'sorting' => 'asc',
192+
];
193+
194+
$firstPage = $this->getJson(route('conversations.messages.index.cursor', $parameters))
195+
->assertStatus(200);
196+
197+
$this->assertCount(2, $firstPage->json('data'));
198+
$this->assertEquals('message 1', $firstPage->json('data.0.body'));
199+
$this->assertEquals('message 2', $firstPage->json('data.1.body'));
200+
$this->assertNotNull($firstPage->json('next_cursor'));
201+
202+
// Get second page using cursor
203+
$nextCursor = $firstPage->json('next_cursor');
204+
$parameters['cursor'] = $nextCursor;
205+
206+
$secondPage = $this->getJson(route('conversations.messages.index.cursor', $parameters))
207+
->assertStatus(200);
208+
209+
$this->assertCount(2, $secondPage->json('data'));
210+
$this->assertEquals('message 3', $secondPage->json('data.0.body'));
211+
$this->assertEquals('message 4', $secondPage->json('data.1.body'));
212+
213+
// Get third page
214+
$nextCursor = $secondPage->json('next_cursor');
215+
$parameters['cursor'] = $nextCursor;
216+
217+
$thirdPage = $this->getJson(route('conversations.messages.index.cursor', $parameters))
218+
->assertStatus(200);
219+
220+
$this->assertCount(1, $thirdPage->json('data'));
221+
$this->assertEquals('message 5', $thirdPage->json('data.0.body'));
222+
$this->assertNull($thirdPage->json('next_cursor'));
223+
}
224+
225+
public function test_index_with_cursor_descending_order()
226+
{
227+
$conversation = factory(Conversation::class)->create();
228+
$userModel = factory(User::class)->create();
229+
$clientModel = factory(Client::class)->create();
230+
231+
Chat::conversation($conversation)->addParticipants([$userModel, $clientModel]);
232+
Chat::message('message 1')->from($userModel)->to($conversation)->send();
233+
Chat::message('message 2')->from($clientModel)->to($conversation)->send();
234+
Chat::message('message 3')->from($userModel)->to($conversation)->send();
235+
236+
$parameters = [
237+
$conversation->getKey(),
238+
'participant_id' => $userModel->getKey(),
239+
'participant_type' => $userModel->getMorphClass(),
240+
'perPage' => 2,
241+
'sorting' => 'desc',
242+
];
243+
244+
$response = $this->getJson(route('conversations.messages.index.cursor', $parameters))
245+
->assertStatus(200);
246+
247+
$this->assertCount(2, $response->json('data'));
248+
// In descending order, newest messages come first
249+
$this->assertEquals('message 3', $response->json('data.0.body'));
250+
$this->assertEquals('message 2', $response->json('data.1.body'));
251+
}
252+
253+
public function test_index_with_cursor_requires_participant()
254+
{
255+
$conversation = factory(Conversation::class)->create();
256+
257+
$parameters = [
258+
$conversation->getKey(),
259+
'perPage' => 10,
260+
];
261+
262+
$this->getJson(route('conversations.messages.index.cursor', $parameters))
263+
->assertStatus(422)
264+
->assertJsonValidationErrors(['participant_id', 'participant_type']);
265+
}
130266
}

0 commit comments

Comments
 (0)