Skip to content
This repository was archived by the owner on Sep 9, 2022. It is now read-only.

Commit 2727ba2

Browse files
committed
Date cursors + identifier autodetect
1 parent 7a154e7 commit 2727ba2

File tree

8 files changed

+200
-21
lines changed

8 files changed

+200
-21
lines changed

README.md

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ This value is automatically detected by the package taking your custom config in
6666
public function index()
6767
{
6868
$users = DB::table('users')->cursorPaginate();
69-
7069
return $users;
7170
}
7271
````
@@ -95,6 +94,81 @@ $users = User::orderBy('id', 'desc')->cursorPaginate(15);
9594
Don't worry, the package will detect if the model's primary key is being used for sorting, and it will adapt
9695
itself so the next and previous work as expected.
9796

97+
### Identifier
98+
99+
The paginator identifier is basically the cursor attribute. The model's attribute that will be used for paginating.
100+
It's really important that this identifier is **unique** on the results. Duplicated identifiers could cause that some
101+
records are not showed, so be careful.
102+
103+
*If the query is not sorted by the identifier, the cursor pagination might not work as expected.*
104+
105+
#### Auto-detecting Identifier
106+
107+
If no identifier is defined, the cursor will try to figure it out by itself. First, it will check if there's any orderBy clause.
108+
If there is, it will take the **first column** that is sorted and will use that.
109+
If there is not any orderBy clause, it will check if it's an Eloquent model, and will use it's `primaryKey` (by default it's 'id').
110+
And if it's not an Eloquent Model, it'll use `id`.
111+
112+
##### Example
113+
114+
````php
115+
// Will use Booking's primaryKey
116+
Bookings::cursorPaginate(10);
117+
````
118+
119+
````php
120+
// Will use hardcoded 'id'
121+
DB::table('bookings')->cursorPaginate(10);
122+
````
123+
124+
````php
125+
// Will use 'created_by'
126+
Bookings::orderBy('created_by', 'asc')
127+
->cursorPaginate(10);
128+
````
129+
130+
#### Customizing Identifier
131+
132+
Just define the `identifier` option
133+
134+
````php
135+
// Will use _id, ignoring everything else.
136+
Bookings::cursorPaginate(10, ['*'], [
137+
'identifier' => '_id'
138+
]);
139+
````
140+
141+
### Date cursors
142+
143+
By default, the identifier is the model's primaryKey (or `id` in case it's not an Eloquent model) so there are no duplicates,
144+
but you can adjust this by passing a custom `identifier` option. In case that specified identifier is casted to date or datetime
145+
on Eloquent's `$casts` property, the paginator will convert it into **unix timestamp** for you.
146+
147+
You can also specify it manually by adding a `[ 'date_identifier' => true ]` option.
148+
149+
##### Example
150+
151+
Using Eloquent (make sure Booking Model has ` protected $casts = ['datetime' => 'datetime']; `)
152+
153+
````php
154+
// It will autodetect 'datetime' as identifier,
155+
// and will detect it's casted to datetime.
156+
Bookings::orderBy('datetime', 'asc')
157+
->cursorPaginate(10);
158+
````
159+
160+
Using Query
161+
````php
162+
// It will autodetect 'datetime' as identifier,
163+
// but since there is no model, you'll have to
164+
// specify the 'date_identifier' option to `true`
165+
DB::table('bookings')
166+
->orderBy('datetime', 'asc')
167+
->cursorPaginate(10, ['*'], [
168+
'date_identifier' => true
169+
]);
170+
````
171+
98172
### Inherits Laravel's Pagination
99173

100174
You should know that CursorPaginator inherits from Laravel's AbstractPaginator, so methods like
@@ -125,7 +199,7 @@ Calling `api/v1` will output:
125199
"next_page_url": "api/v1?next_cursor=3",
126200
"prev_page_url": "api/v1?previous_cursor=1",
127201
"data": [
128-
{},
202+
{}
129203
]
130204
}
131205
````
@@ -138,7 +212,7 @@ into `links` and `meta`.
138212
````json
139213
{
140214
"data":[
141-
{},
215+
{}
142216
],
143217
"links": {
144218
"first": null,
@@ -151,7 +225,7 @@ into `links` and `meta`.
151225
"previous_cursor": "1",
152226
"next_cursor": "3",
153227
"per_page": 3
154-
},
228+
}
155229
}
156230
````
157231

@@ -193,8 +267,9 @@ To configure that, set `$perPage = [15, 5]`. That way it'll fetch 15 when you do
193267
````php
194268
new CursorPaginator(array|collection $items, array|int $perPage, array options = [
195269
// Attribute used for choosing the cursor. Used primaryKey on Eloquent Models as default.
196-
'identifier' => 'id',
197-
'path' => request()->path(),
270+
'identifier' => 'id',
271+
'date_identifier' => false,
272+
'path' => request()->path(),
198273
]);
199274
````
200275

src/CursorPaginationServiceProvider.php

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,37 @@ public function register()
3939
*/
4040
public function registerMacro()
4141
{
42+
/**
43+
* @param null $perPage default=null
44+
* @param array $columns default=['*']
45+
* @param array $options
46+
*
47+
* @return CursorPaginator
48+
*/
4249
$macro = function ($perPage = null, $columns = ['*'], array $options = []) {
4350

44-
// Use model's key name by default if EloquentBuilder
51+
$query_orders = isset($this->query) ? collect($this->query->orders) : collect($this->orders);
52+
$identifier_sort = null;
53+
54+
// Build the default identifier by considering column sorting and primaryKeys
4555
if (!isset($options['identifier'])) {
46-
$options['identifier'] = isset($this->model) ? $this->model->getKeyName() : 'id';
56+
57+
// Check if has explicit orderBy clause
58+
if ($query_orders->isNotEmpty()) {
59+
// Make the identifier the name of the first sorted column
60+
$identifier_sort = $query_orders->first();
61+
$options['identifier'] = $identifier_sort['column'];
62+
} else {
63+
// If has no orderBy clause, use the primaryKeyName (if it's a Model), or the default 'id'
64+
$options['identifier'] = isset($this->model) ? $this->model->getKeyName() : 'id';
65+
}
66+
67+
} else {
68+
$identifier_sort = $query_orders->firstWhere('column', $options['identifier']);
69+
}
70+
71+
if (!isset($options['date_identifier']) && isset($this->model)) {
72+
$options['date_identifier'] = $this->model->hasCast($options['identifier'], ['datetime', 'date']);
4773
}
4874

4975
if (!isset($options['request'])) {
@@ -53,10 +79,8 @@ public function registerMacro()
5379
// Resolve the cursor by using the request query params
5480
$cursor = CursorPaginator::resolveCurrentCursor($options['request']);
5581

56-
$query_orders = isset($this->query) ? $this->query->orders : $this->orders;
57-
58-
$identifier_sort_inverted = collect($query_orders)->firstWhere('column', $options['identifier']);
59-
$identifier_sort_inverted = $identifier_sort_inverted ? $identifier_sort_inverted['direction'] === 'desc' : false;
82+
// If there's a sorting by the identifier, check if it's desc so the cursor is inverted
83+
$identifier_sort_inverted = $identifier_sort ? $identifier_sort['direction'] === 'desc' : false;
6084

6185
if ($cursor->isPrev()) {
6286
$this->where($options['identifier'], $identifier_sort_inverted ? '>' : '<', $cursor->getPrevCursor());
@@ -65,6 +89,7 @@ public function registerMacro()
6589
$this->where($options['identifier'], $identifier_sort_inverted ? '<' : '>', $cursor->getNextCursor());
6690
}
6791

92+
// Use configs perPage if it's not defined
6893
if (is_null($perPage)) {
6994
$perPage = config('cursor_pagination.per_page', 10);
7095
}

src/CursorPaginator.php

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ class CursorPaginator extends AbstractPaginator implements Arrayable, ArrayAcces
2828
*/
2929
protected $identifier = 'id';
3030

31+
/**
32+
* Should cast to date the identifier
33+
*
34+
* @var bool
35+
*/
36+
protected $date_identifier = false;
37+
3138
/**
3239
* @var Request
3340
*/
@@ -239,7 +246,7 @@ public function isFirstPage()
239246
*/
240247
public function firstItem()
241248
{
242-
return optional($this->items->first())->{$this->identifier};
249+
return $this->getIdentifier($this->items->first());
243250
}
244251

245252
/**
@@ -249,7 +256,29 @@ public function firstItem()
249256
*/
250257
public function lastItem()
251258
{
252-
return optional($this->items->last())->{$this->identifier};
259+
return $this->getIdentifier($this->items->last());
260+
}
261+
262+
/**
263+
* Gets and casts identifier.
264+
*
265+
* @param $model
266+
*
267+
* @return mixed|null
268+
*/
269+
protected function getIdentifier($model)
270+
{
271+
if (!isset($model)) {
272+
return null;
273+
}
274+
275+
$id = $model->{$this->identifier};
276+
277+
if (!$this->date_identifier) {
278+
return $id;
279+
}
280+
281+
return (is_string($id)) ? strtotime($id) : $id->timestamp;
253282
}
254283

255284
/**

tests/Fixtures/Models/User.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ class User extends Model
99
protected $guarded = [];
1010

1111
protected $primaryKey = '_id';
12+
13+
protected $casts = ['datetime' => 'datetime'];
1214
}

tests/MacroTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,48 @@ public function test_both_pagination()
104104

105105
$this->assertEquals(count($p->toArray()['data']), $prev_val - $next_val - 1);
106106
}
107+
108+
public function test_sorted_by_date()
109+
{
110+
$p = User::orderBy('datetime', 'asc')->cursorPaginate(10, ['*'], [
111+
'identifier' => 'datetime',
112+
'path' => '/',
113+
]);
114+
115+
$this->assertAttributeEquals(true, 'date_identifier', $p);
116+
117+
$this->assertGreaterThanOrEqual(strtotime('last month'), $p->prevCursor());
118+
$this->assertLessThanOrEqual(strtotime('now'), $p->prevCursor());
119+
$this->assertGreaterThanOrEqual(strtotime('last month'), $p->nextCursor());
120+
$this->assertLessThanOrEqual(strtotime('now'), $p->nextCursor());
121+
}
122+
123+
public function test_sorted_by_date_on_query()
124+
{
125+
$p = \DB::table('users')
126+
->orderBy('datetime', 'asc')
127+
->cursorPaginate(10, ['*'], [
128+
'identifier' => 'datetime',
129+
'date_identifier' => true,
130+
'path' => '/',
131+
]);
132+
133+
$this->assertAttributeEquals(true, 'date_identifier', $p);
134+
$this->assertGreaterThanOrEqual(strtotime('last month'), $p->prevCursor());
135+
$this->assertLessThanOrEqual(strtotime('now'), $p->prevCursor());
136+
$this->assertGreaterThanOrEqual(strtotime('last month'), $p->nextCursor());
137+
$this->assertLessThanOrEqual(strtotime('now'), $p->nextCursor());
138+
}
139+
140+
public function test_sorted_by_date_auto_identifier()
141+
{
142+
$p = User::orderBy('datetime', 'asc')->cursorPaginate(10, ['*'], ['path' => '/']);
143+
144+
$this->assertAttributeEquals('datetime', 'identifier', $p);
145+
$this->assertAttributeEquals(true, 'date_identifier', $p);
146+
$this->assertGreaterThanOrEqual(strtotime('last month'), $p->prevCursor());
147+
$this->assertLessThanOrEqual(strtotime('now'), $p->prevCursor());
148+
$this->assertGreaterThanOrEqual(strtotime('last month'), $p->nextCursor());
149+
$this->assertLessThanOrEqual(strtotime('now'), $p->nextCursor());
150+
}
107151
}

tests/ModelsTestCase.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,15 @@ protected function setUpDatabase($app)
3939
$app['db']->connection()->getSchemaBuilder()->create('users', function (Blueprint $table) {
4040
$table->increments('_id');
4141
$table->string('name');
42+
$table->dateTime('datetime');
4243
$table->rememberToken();
4344
$table->timestamps();
4445
});
4546
foreach (range(1, 40) as $index) {
46-
User::create(['name' => "user{$index}"]);
47+
User::create([
48+
'name' => "user{$index}",
49+
'datetime' => date("Y-m-d H:i:s", mt_rand(strtotime('last month'), strtotime('now'))),
50+
]);
4751
}
4852
}
4953

tests/RequestTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ public function test_inverted_order()
178178
$this->assertEquals($ids, array_reverse(range($next_cur - 5, $next_cur - 1)));
179179
}
180180

181-
public function _test_on_query()
181+
public function test_on_query()
182182
{
183183
list($prev_name, $next_name) = CursorPaginator::cursorQueryNames(true);
184184
$next_cur = 36;

tests/UrlDetectionTest.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public function test_resolves_urls_on_no_cursor()
3232

3333
public function test_resolves_no_prev_on_cursor()
3434
{
35-
list($prev_name, $next_name) = CursorPaginator::cursorQueryNames(true);
35+
list(, $next_name) = CursorPaginator::cursorQueryNames(true);
3636

3737
$p = new CursorPaginator($array = [
3838
(object) ['id' => 2],
@@ -56,7 +56,7 @@ public function test_resolves_no_prev_on_cursor()
5656

5757
public function test_resolves_with_other_query()
5858
{
59-
list($prev_name, $next_name) = CursorPaginator::cursorQueryNames(true);
59+
list(, $next_name) = CursorPaginator::cursorQueryNames(true);
6060

6161
$p = new CursorPaginator($array = [
6262
(object) ['id' => 2],
@@ -77,7 +77,7 @@ public function test_resolves_with_other_query()
7777

7878
public function test_resolves_prev_intact_if_no_elements()
7979
{
80-
list($prev_name, $next_name) = CursorPaginator::cursorQueryNames(true);
80+
list($prev_name,) = CursorPaginator::cursorQueryNames(true);
8181

8282
$p = new CursorPaginator($array = [], $perPage = 2, [
8383
'request' => new Request([
@@ -91,7 +91,7 @@ public function test_resolves_prev_intact_if_no_elements()
9191

9292
public function test_stops_when_no_more_items()
9393
{
94-
list($prev_name, $next_name) = CursorPaginator::cursorQueryNames(true);
94+
list(, $next_name) = CursorPaginator::cursorQueryNames(true);
9595

9696
$p = new CursorPaginator($array = [
9797
(object) ['id' => 98],
@@ -110,7 +110,7 @@ public function test_stops_when_no_more_items()
110110

111111
public function test_different_identifier()
112112
{
113-
list($prev_name, $next_name) = CursorPaginator::cursorQueryNames(true);
113+
CursorPaginator::cursorQueryNames(true);
114114

115115
$p = new CursorPaginator($array = [
116116
(object) ['id' => 98, '_id' => 1],

0 commit comments

Comments
 (0)