Cursor-based Pagination in NeNe¶
NeNe provides DataMapperBase::findPage() for keyset (cursor-based) pagination.
Cursor pagination is preferred over OFFSET for any list that may exceed a few
hundred rows — deep offsets are slow and unstable under concurrent inserts.
How it works¶
findPage() encodes a (created_at, id) pair as an opaque base64url token.
The client passes the token back as ?after=<cursor> to fetch the next page.
// Controller
$cursor = $this->REQUEST->get('after'); // ?after=<token> or null
$limit = (int)($this->REQUEST->get('limit') ?? 20);
$page = $this->mapper->findPage($cursor !== '' ? $cursor : null, $limit);
return $this->API_RESPONSE->success($page->toArray());
Response shape:
{
"Result": true,
"Data": {
"status": "success",
"errorCode": "",
"items": [...],
"has_more": true,
"next_cursor": "eyJjIjoiMjAyNi0wNS0yNyAxMjowMDowMCIsImkiOjQyfQ"
}
}
Requirements¶
- The table must have a
created_atcolumn (constantDB_COLUMN_NAME_CREATED). - The primary key column is taken from
static::KEY_SID(default:id). - Records are returned newest-first (
created_at DESC, id DESC).
Limit clamping¶
$limit is clamped to [1, 100] automatically.
Invalid cursor handling¶
Cursor::decode() returns null for any malformed token.
findPage() treats a null cursor as a first-page request (no filter applied).
Return a 400 to the client if you want to reject invalid cursors explicitly:
use Nene\Xion\Cursor;
$raw = $this->REQUEST->get('after');
if ($raw !== null && $raw !== '' && Cursor::decode($raw) === null) {
// 400 Bad Request — invalid cursor
return $this->API_RESPONSE->failure('INVALID_CURSOR');
}
$page = $this->mapper->findPage($raw ?: null, $limit);
OpenAPI snippet¶
parameters:
- name: after
in: query
required: false
schema:
type: string
description: Opaque cursor token returned by the previous page's next_cursor.
- name: limit
in: query
required: false
schema:
type: integer
default: 20
minimum: 1
maximum: 100
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/CursorPageEnvelope"
components:
schemas:
CursorPageEnvelope:
type: object
required: [Result, Data]
properties:
Result:
type: boolean
Data:
type: object
required: [status, errorCode, items, has_more, next_cursor]
properties:
status:
type: string
enum: [success]
errorCode:
type: string
items:
type: array
items: {}
has_more:
type: boolean
next_cursor:
type: string
nullable: true