Skip to content
academia.sh

Lesson 12 / 21

Pagination and Resource Transformation

Offset pagination and a resource transformation from model to response array are built; the lesson measures that a new column added to the model is not wired to the response because it is not in the transformation definition, that it runs once added to the definition, and that a request outside the page range returns an empty list.

Published Updated

In this lesson

  • Builds offset pagination with total, page and last page information
  • Writes a resource transformation from model to response array and a resource collection
  • Measures that a new column added to the model is not wired to the response unless it is in the transformation definition
  • Reads from real output the result of a request outside the page range
Contents

The migration added the new column to the schema. Does that column reach the API response on its own? The model knows the column, but the response is built somewhere else, in a transformation definition. If the link between the two is not made on its own, the column may never appear in the response even though it was added.

The measurement axis of this lesson is whether a column or a step ran in this response. The decision order is the classify() function itself: ran, deferred, skipped, not wired. The dominant outcome is not wired, and as in the core lesson it is the most dangerous one: a skipped step shows as a status code and a deferred step shows in the worker; a step that is not wired shows nowhere, and the response returns 200.

VD20. The table is a PHP array of twenty-five rows, and the rows carry id, title, summary and the later-added reading_time column; reading_time is a neutral number. VD21. Pagination is offset based: the page number starts at one, and the number of skipped rows is (page - 1) * size. VD22. The response status returns as a [status, body] pair, with a small slice of the action; the HTTP layer is not built. VD23. The resource transformation takes every field in the definition from the model in order.

The measurement setup comes unchanged.

<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}

Pagination and the Resource

Pagination splits a long list into pages. Offset pagination takes the page number and the page size, skips the rows before the page, and returns as many rows as the page size; the total row count and the last page number are put in the response so that the client knows how many pages there are. Resource transformation is the layer that turns a model row into a response array. A resource collection applies this transformation to every row of a page and adds the page information as metadata (meta).

The transformation definition names which fields will enter the response. A field that is in the model and not in the definition neither enters the response nor produces an error. This is the purpose of resource transformation: the shape of the response does not change on its own when the model changes, so the transformation is a contract that protects the response from changes in the model. The same property also makes it possible to forget a new column.

<?php
// pagination.php — offset pagination and resource transformation
declare(strict_types=1);
require_once 'ran.php';

function posts(): array {
    return array_map(
        fn($i) => ['id' => $i, 'title' => "Post $i", 'summary' => "Summary $i", 'reading_time' => ($i % 5) + 1],
        range(1, 25)
    );
}

function paginate(array $rows, int $page, int $size): array {
    Ledger::ran('pagination');
    $total = count($rows);
    return [
        'rows' => array_slice($rows, ($page - 1) * $size, $size),
        'page' => $page, 'size' => $size, 'total' => $total,
        'last' => (int) ceil($total / $size),
    ];
}

final class Resource {
    public function __construct(private array $fields) {}

    public function transform(array $row): array {
        $output = [];
        foreach ($this->fields as $field) {
            Ledger::onPath("field:$field");
            Ledger::ran("field:$field");
            $output[$field] = $row[$field];
        }
        return $output;
    }

    public function collection(array $page): array {
        return [
            'data' => array_map(fn($r) => $this->transform($r), $page['rows']),
            'meta' => ['page' => $page['page'], 'size' => $page['size'], 'total' => $page['total'], 'last' => $page['last']],
        ];
    }
}

function postsAction(Resource $resource, int $page, int $size = 10): array {
    return [200, $resource->collection(paginate(posts(), $page, $size))];
}

function printResponse(array $response): void {
    [$status, $body] = $response;
    $m = $body['meta'];
    echo "status $status\n";
    echo "  page={$m['page']} size={$m['size']} total={$m['total']} last={$m['last']}\n";
    echo "  records=" . count($body['data']) . "\n";
    if ($body['data'] !== []) echo "  keys=" . implode(',', array_keys($body['data'][0])) . "\n";
}
function report(array $parts): void {
    foreach ($parts as $part) printf("  %-18s %s\n", $part, classify($part));
}

Does the New Column Reach the Response

The table carries the reading_time column, but the transformation definition names only the id, title and summary fields. The measurement asks for the second page. Expected: status 200, ten records, a total of twenty-five, three as the last page; pagination ran, title ran, reading_time not wired.

<?php
// new_column.php — a column not in the definition is not wired to the response
require_once 'pagination.php';

Ledger::newRequest();
printResponse(postsAction(new Resource(['id', 'title', 'summary']), 2));
report(['pagination', 'field:title', 'field:reading_time']);
status 200
  page=2 size=10 total=25 last=3
  records=10
  keys=id,title,summary
  pagination         ran
  field:title        ran
  field:reading_time not wired

The response is 200, the page information is right, and there are three keys. reading_time is in every row of the table, but it is not in the response, and there is no trace of this: no warning, no error. Because the transformation does not know the new column, the column was neither placed on the path nor called: not wired. The client goes on getting the old shape without knowing that the column was added; the developer who added the column sees from the test only that the old fields are present, and the test passes.

The Opposing Measurement: Adding It to the Definition

The same request is made with a resource that has reading_time added to its definition. Expected: the field ran and the number of keys is four.

<?php
// defined.php — a column added to the definition reaches the response
require_once 'pagination.php';

Ledger::newRequest();
printResponse(postsAction(new Resource(['id', 'title', 'summary', 'reading_time']), 2));
report(['pagination', 'field:title', 'field:reading_time']);
status 200
  page=2 size=10 total=25 last=3
  records=10
  keys=id,title,summary,reading_time
  pagination         ran
  field:title        ran
  field:reading_time ran

The same page and the same page information; the difference is only in the definition. Once the column is added to the definition it ran. The shape of the response thus changes on purpose: a line in the definition is a change to the response contract, and it is visible in review. That a change in the model does not change the response on its own is the protection; its cost is that this definition is maintained by hand.

The Bounding Measurement

Does a request outside the page range return an empty list or an error? The table has twenty-five rows and the page size is ten; the last page is three. For comparison, the measurement asks for the third page, then the fourth and the ninety-ninth. Because pagination is done with array_slice, a page outside the range works without a special check.

<?php
// out_of_range.php — a request outside the page range
require_once 'pagination.php';

foreach ([3, 4, 99] as $page) {
    Ledger::newRequest();
    echo "requested page $page\n";
    printResponse(postsAction(new Resource(['id', 'title', 'summary']), $page));
    report(['pagination', 'field:title']);
}
requested page 3
status 200
  page=3 size=10 total=25 last=3
  records=5
  keys=id,title,summary
  pagination         ran
  field:title        ran
requested page 4
status 200
  page=4 size=10 total=25 last=3
  records=0
  pagination         ran
  field:title        not wired
requested page 99
status 200
  page=99 size=10 total=25 last=3
  records=0
  pagination         ran
  field:title        not wired

In the output a page outside the range is not an error: the status is 200 and the record count is zero. The metadata returns the requested page number as it is, and the last page stays three. What stands out is the field:title record on an out-of-range page: because there were no rows, the transformation never ran, and the field fell into the not wired outcome. The same field ran on the third page. So the answer to the question “did this field run in the response” depends on whether there are rows in the response. A client can read an empty list as “there are no records on this page”, but cannot tell it apart from an error; if an error is wanted, an explicit check comparing the page number with last is needed.

Summary

  • Offset pagination produces the page, the page size, the total and the last page information; resource transformation turns the model into a response array.
  • This lesson’s outcome is not wired: the column added to the model did not reach the response because it was not in the transformation definition, the response returned 200, and no error was seen.
  • A column added to the definition ran; a change in the shape of the response thus becomes a visible decision.
  • Resource transformation is a contract that protects the response from changes in the model; adding the new column to the definition by hand is the price of this protection.
  • A request outside the page range returned 200 and an empty list; the transformation never ran because there were no rows.

Next Step

The response is ready, but we have not yet looked at who is asking for it. What happens if a user without permission asks for the same list? The first lesson of the next topic, application services, Authentication and Authorization, takes up this question.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close