DownloadPDO Pagination Contract
1. Status and Authority
Architecture status: owner-approved
Runtime status: implemented and released in v1.1.0
Architecture decision: ADR 0001 ? PDO Pagination Architecture.
Implementation authorization: Implementation has been completed and merged.
Released in: v1.1.0
This document is the package-specific source of truth for implementing:
Maatify\Persistence\Pdo\Pagination
It supersedes earlier host-project pagination proposals when they conflict with this contract.
After implementation, the Runtime source and PERSISTENCE_PACKAGE_REFERENCE.md MUST match this document exactly. Any proposed divergence requires a new architectural decision before code changes.
2. Problem Definition
Maatify host projects currently duplicate PDO offset-pagination behavior across repositories and query readers. The repeated concerns include:
-
page normalization
-
per-page normalization and limits
-
total count
-
filtered count
-
deterministic sorting
-
safe public sort keys
-
stable tie-breakers
-
`LIMIT` / `OFFSET` calculation
-
typed PDO parameter binding
-
result metadata
-
row mapping
The package will provide one reusable, standalone implementation so host repositories retain ownership of domain SQL while no longer reimplementing pagination mechanics.
3. Architectural Placement
Pagination is a new domain alongside Ordering:
Maatify\Persistence\Pdo\Ordering
Maatify\Persistence\Pdo\Pagination
It MUST NOT be added to:
ScopedOrderingConfig
ScopedOrderingManager
No existing Ordering constructor, method, exception, transaction rule, SQL behavior, or public return contract may be changed by this feature.
The Pagination domain is an additive Minor-version capability.
4. Package Boundaries
The component MUST remain:
-
standalone
-
framework-agnostic
-
host-agnostic
-
PDO-based
-
MySQL-verified (initial support is PDO driver `mysql` only)
-
independent of Slim
-
independent of PSR-7
-
independent of HTTP request/response objects
-
independent of container bindings
-
independent of host tables and host namespaces
The component is not:
-
a Query Builder
-
an ORM
-
a search builder
-
a filter builder
-
a repository abstraction
-
an authorization layer
-
a response emitter
-
request middleware
The host supplies the PDO connection, trusted SQL, mandatory scopes, optional filters, and row mapper.
5. Version-1 Runtime Inventory
The approved Runtime files are:
src/Pdo/Pagination/PageRequest.php
src/Pdo/Pagination/PaginationConfig.php
src/Pdo/Pagination/SortDirectionEnum.php
src/Pdo/Pagination/SortWhitelist.php
src/Pdo/Pagination/PdoPaginationQueryDescriptor.php
src/Pdo/Pagination/PdoPaginator.php
src/Pdo/Pagination/PageResult.php
src/Exception/InvalidPaginationConfigurationException.php
src/Exception/InvalidPaginationQueryException.php
src/Exception/PaginationExecutionException.php
Version 1 MUST NOT add:
PaginationException
PaginatorInterface
RowMapperInterface
FilterWhitelist
SearchBuilder
The existing package marker remains:
Maatify\Persistence\Exception\PersistenceException
All new package-defined Pagination exceptions implement that marker.
6. Exact Public API
The following signatures are the approved implementation contract.
6.1 PageRequest
namespace Maatify\Persistence\Pdo\Pagination;
final readonly class PageRequest
{
public function __construct(
public int|string|null $page = null,
public int|string|null $perPage = null,
public ?string $sortBy = null,
public ?string $sortDirection = null,
) {
}
}
Contract:
-
`PageRequest` is a request value object.
-
Represents caller-supplied values before final normalization.
-
Contains no HTTP or PSR-7 knowledge.
-
Does not normalize or execute queries.
-
`sortDirection` is raw input; the final applied direction is represented by `SortDirectionEnum`.
6.2 SortDirectionEnum
namespace Maatify\Persistence\Pdo\Pagination;
enum SortDirectionEnum: string
{
case ASC = 'ASC';
case DESC = 'DESC';
}
No additional public methods are approved in version 1.
6.3 SortWhitelist
Class declaration:
final readonly class SortWhitelist
Public signatures:
/
* @param non-empty-array<non-empty-string, non-empty-string> $sorts
*/
public function __construct(array $sorts);
public function contains(string $key): bool;
/
* @return non-empty-string
*/
public function quotedIdentifierFor(string $key): string;
Contract:
-
Constructor keys are public sort keys.
-
Constructor values are trusted identifier paths.
-
`contains()` is an exact, case-sensitive public-key lookup.
-
`quotedIdentifierFor()` returns a validated, internally quoted identifier.
-
Resolving a missing key is an invalid configuration operation and throws `InvalidPaginationConfigurationException`.
-
The internal whitelist map is immutable and is not exposed through a public getter.
6.4 PaginationConfig
namespace Maatify\Persistence\Pdo\Pagination;
final readonly class PaginationConfig
{
public function __construct(
public SortWhitelist $sortWhitelist,
public string $defaultSortBy,
public SortDirectionEnum $defaultSortDirection,
public string $tieBreakerSortBy,
public SortDirectionEnum $tieBreakerDirection,
public int $defaultPerPage = 20,
public int $minPerPage = 1,
public int $maxPerPage = 200,
) {
}
}
Constructor validation:
-
`minPerPage >= 1`
-
`maxPerPage >= minPerPage`
-
`defaultPerPage` is inside `[minPerPage, maxPerPage]`
-
`defaultSortBy` is a valid public key and exists in `sortWhitelist`
-
`tieBreakerSortBy` is a valid public key and exists in `sortWhitelist`
defaultSortBy and tieBreakerSortBy MAY resolve to the same identifier.
The tie-breaker uniqueness guarantee defined in section 8.4 is caller-owned. PaginationConfig validates public keys and whitelist membership, but it cannot prove database uniqueness from configuration alone.
The values 20, 1, and 200 are the canonical constructor defaults. Callers MAY configure different valid per-page values through PaginationConfig; no separate architectural exception is required when the constructor invariants remain satisfied.
6.5 PdoPaginationQueryDescriptor
namespace Maatify\Persistence\Pdo\Pagination;
final readonly class PdoPaginationQueryDescriptor
{
/
* @param array<string, string|int|bool|null> $totalParams
* @param array<string, string|int|bool|null> $filteredCountParams
* @param array<string, string|int|bool|null> $dataParams
*/
public function __construct(
public string $totalSql,
public array $totalParams,
public string $filteredCountSql,
public array $filteredCountParams,
public string $dataSql,
public array $dataParams,
) {
}
}
All three SQL strings and all three parameter maps are required, including empty parameter maps.
Contract:
-
`PdoPaginationQueryDescriptor` is a SQL descriptor and does not execute SQL.
6.6 PageResult
Class declaration:
/
* @template T of array|object
*/
final readonly class PageResult implements \JsonSerializable
Constructor:
/
* @param list<T> $data
*/
public function __construct(
public array $data,
public int $page,
public int $perPage,
public int $total,
public int $filtered,
public int $totalPages,
public bool $hasNext,
public bool $hasPrevious,
public string $sortBy,
public SortDirectionEnum $sortDirection,
);
Contract:
-
`PageResult` is a result value object.
-
Direct instantiation of `PageResult` is supported Public API provided invariants are respected.
Constructor invariants:
-
`data` MUST satisfy `array_is_list($data)`
-
every item in `data` MUST be an array or object
-
`count($data) <= $perPage`
-
`page >= 1`
-
`perPage >= 1`
-
`total >= 0`
-
`filtered >= 0`
-
`totalPages >= 0`
-
`totalPages` MUST equal the canonical value calculated from `filtered` and `perPage`:
`0` when `filtered === 0`, otherwise `intdiv($filtered - 1, $perPage) + 1`
-
when `filtered === 0`, `data === []`
-
when `totalPages === 0`, `page === 1` and both navigation flags are `false`
-
when `totalPages > 0`, `page <= totalPages`
-
`hasNext === ($page < $totalPages)`
-
`hasPrevious === ($page > 1 && $totalPages > 0)`
-
`sortBy` MUST match `^[A-Za-z_][A-Za-z0-9_]*$`
An empty data list while filtered > 0 is valid because concurrent writes may change the dataset between the count and data statements.
An inconsistent result state is a package-owned execution failure and MUST throw PaginationExecutionException.
Public serialization signatures:
/
* @return array{
* data: list<T>,
* pagination: array{
* page: int,
* per_page: int,
* total: int,
* filtered: int,
* total_pages: int,
* has_next: bool,
* has_previous: bool,
* sort_by: string,
* sort_direction: 'ASC'|'DESC'
* }
* }
*/
public function toArray(): array;
/
* @return array{
* data: list<T>,
* pagination: array{
* page: int,
* per_page: int,
* total: int,
* filtered: int,
* total_pages: int,
* has_next: bool,
* has_previous: bool,
* sort_by: string,
* sort_direction: 'ASC'|'DESC'
* }
* }
*/
public function jsonSerialize(): array;
6.7 PdoPaginator
Class declaration:
final readonly class PdoPaginator
Public signature:
/
* @template T of array|object
*
* @param callable(array<string, mixed>): T $mapper
*
* @return PageResult<T>
*/
public function paginate(
\PDO $pdo,
PdoPaginationQueryDescriptor $query,
PageRequest $request,
PaginationConfig $config,
callable $mapper,
): PageResult;
PdoPaginator is stateless and has no explicitly declared constructor.
7. Page and Per-Page Normalization
7.1 Accepted Numeric Form
page and perPage accept:
-
an integer
-
a string which, after trimming, contains an optional leading sign followed only by decimal digits
-
`null`
Examples accepted as numeric input:
2
"2"
" 2 "
"+2"
"02"
"-2"
Examples rejected as malformed numeric input:
""
" "
"2.0"
"2e3"
"1_000"
"1,000"
"abc"
A syntactically numeric string whose magnitude cannot be represented as a PHP integer is unrepresentable.
7.2 Page Rules
-
missing, malformed, or unrepresentable => `1`
-
parsed value below `1` => `1`
-
otherwise => parsed integer
-
after filtered count, page greater than positive `totalPages` => `1`
Page input failures do not throw.
7.3 Per-Page Rules
-
missing, malformed, or unrepresentable => `defaultPerPage`
-
parsed value below `minPerPage` => `minPerPage`
-
parsed value above `maxPerPage` => `maxPerPage`
-
otherwise => parsed integer
Per-page input failures do not throw.
8. Sort Input and Whitelist Contract
8.1 Public Sort Keys
Public keys MUST match:
^[A-Za-z_][A-Za-z0-9_]*$
Lookup is case-sensitive.
sortBy is trimmed before lookup:
-
missing, empty, malformed, or absent from whitelist => `defaultSortBy`
-
valid and present => requested key
The response returns the effective public key.
8.2 Sort Direction Input
sortDirection is trimmed and compared case-insensitively:
-
`asc` / `ASC` => `SortDirectionEnum::ASC`
-
`desc` / `DESC` => `SortDirectionEnum::DESC`
-
missing, empty, or invalid => `defaultSortDirection`
The response always emits uppercase enum values.
8.3 Identifier Paths
Whitelist values MUST contain one to three identifier segments:
column
alias.column
schema.table.column
Each segment MUST match:
^[A-Za-z_][A-Za-z0-9_]*$
The whitelist quotes each segment with backticks:
created_at => `created_at`
v.created_at => `v`.`created_at`
catalog.products.created_at => `catalog`.`products`.`created_at`
Version 1 forbids arbitrary ordering expressions, including:
-
functions
-
parentheses
-
arithmetic
-
JSON operators
-
`CASE`
-
`COLLATE`
-
commas
-
directions
-
comments
-
semicolons
-
`LIMIT`
-
`OFFSET`
8.4 Tie-Breaker Rules
One internal tie-breaker is mandatory for deterministic pagination.
The resolved tie-breaker identifier MUST produce a unique final ordering within every filtered dataset to which the configuration is applied. A primary key such as id is the usual choice.
The caller owns this uniqueness guarantee. The component validates and quotes the identifier but does not inspect schema constraints or prove uniqueness.
If the effective primary and tie-breaker public keys resolve to different quoted identifiers:
ORDER BY {primary} {effective_direction}, {tie_breaker} {configured_direction}
If they resolve to the same quoted identifier, including through two different public keys:
ORDER BY {primary} {effective_direction}
In the duplicate-identifier case, that single resolved identifier MUST itself be unique within the filtered dataset. The effective primary direction wins; the tie-breaker direction is not emitted.
Using a non-unique final tie-breaker is a trusted configuration defect even though version 1 cannot detect it at Runtime.
9. SQL Descriptor Contract
9.1 totalSql
Counts the base visible dataset after all mandatory constraints and before optional search/filter.
Mandatory constraints include, where applicable:
-
tenant/module scope
-
authorization or ownership scope
-
required visibility policy
-
soft-delete exclusion
-
required system state
It MUST return one non-negative integer scalar.
9.2 filteredCountSql
Counts the same base visible dataset after applying optional search/filter.
It MUST be semantically aligned with dataSql and return one non-negative integer scalar.
9.3 dataSql
Returns the same filtered dataset represented by filteredCountSql.
It excludes:
-
ordering supplied by the paginator
-
`LIMIT`
-
`OFFSET`
9.4 Runtime-Checkable Rules
The descriptor constructor MUST reject:
-
SQL empty after trimming
-
any semicolon inside SQL strings to prevent multi-statements
-
invalid parameter keys
-
parameter keys beginning with `:`
-
parameter keys beginning with the reserved `__pagination_` prefix
-
any SQL string containing a named placeholder beginning with `:__pagination_`
-
unsupported parameter value types
The complete __pagination_ namespace is reserved for package-owned bindings. Detecting that prefix in caller parameter keys or SQL placeholder names is a narrow collision check, not general SQL parsing.
The descriptor constructor does not preflight general placeholder correspondence, repeated placeholder usage, positional placeholder usage, or mixed placeholder styles.
The component does not provide a SQL parser. Top-level ORDER BY, LIMIT, OFFSET, locking clauses, SELECT compatibility, placeholder correspondence, and semantic alignment remain explicit caller contracts except where PDO itself reports failure.
The documentation and tests MUST NOT claim that Regex checks prove complete SQL grammar safety.
10. Parameter Binding Contract
Version 1 supports named placeholders only. Positional ? placeholders and statements that mix positional and named placeholders are unsupported.
Parameter keys MUST match:
^[A-Za-z_][A-Za-z0-9_]*$
They MUST be stored without a leading colon.
Reserved namespace:
__pagination_*
Caller parameter keys beginning with __pagination_ are invalid. Caller SQL placeholder names beginning with :__pagination_ are also invalid.
The package currently uses:
__pagination_limit
__pagination_offset
Supported values and binding:
| PHP value | PDO binding |
|---|---|
| int | PDO::PARAM_INT |
| bool | PDO::PARAM_BOOL |
| null | PDO::PARAM_NULL |
| string | PDO::PARAM_STR |
Unsupported:
-
float
-
array
-
object
-
resource
Decimals are validated strings owned by the caller.
Each SQL statement is executed only with its matching parameter map. One shared map MUST NOT be bound blindly to all statements.
Within one SQL statement:
-
every named placeholder MUST have one unique occurrence
-
every named placeholder MUST have one matching parameter-map entry
-
every parameter-map key MUST be used by one matching named placeholder
-
reusing the same logical value requires distinct placeholder names and matching parameter entries
These are caller contracts so behavior remains valid with native prepared statements. The component does not parse SQL to prove them, and PdoPaginationQueryDescriptor MUST NOT claim general placeholder preflight validation. Violations may surface as an unchanged PDOException or as a package-classified non-throwing PDO failure state, depending on the connection configuration.
The paginator MUST NOT change PDO connection attributes. Runtime does not inspect or reject PDO::ATTR_DRIVER_NAME in version 1; using a non-mysql driver is simply an unsupported out-of-contract usage without introducing a new preflight exception.
11. Count Validation and Metadata
Each count statement MUST return exactly one row containing exactly one column. The paginator MUST verify both cardinalities before accepting the value.
The single count value is then validated using the following representation contract.
Accepted count representation:
-
PHP integer `>= 0`
-
decimal-digit string representing an integer `>= 0` within `PHP_INT_MAX`
Rejected:
-
`false`
-
`null`
-
empty string
-
signed string
-
decimal/floating representation
-
exponent notation
-
negative value
-
value larger than `PHP_INT_MAX`
-
any other type
-
zero rows or more than one row
-
a row containing zero columns or more than one column
totalPages calculation:
$totalPages = $filtered === 0
? 0
: intdiv($filtered - 1, $perPage) + 1;
After zero-result and overflow normalization, and only when filtered > 0, offset calculation is exactly:
$offset = ($page - 1) * $perPage;
The multiplication is integer-safe under this contract because the effective page is never greater than totalPages, and the resulting page start cannot exceed filtered - 1.
The paginator MUST NOT enforce filtered <= total at Runtime. Concurrent writes between independent count statements can temporarily violate that relationship unless the caller supplies an appropriate consistent transaction.
Metadata:
hasNext = page < totalPages
hasPrevious = page > 1 && totalPages > 0
12. Empty and Overflow Behavior
filtered === 0
The paginator:
-
sets effective page to `1`
-
sets `totalPages` to `0`
-
does not prepare or execute `dataSql`
-
returns `data = []`
-
returns both navigation flags as `false`
Requested Page Overflow
When filtered > 0 and normalized page is greater than totalPages:
-
effective page becomes `1`
-
offset becomes `0`
-
the data query executes once for the first page
No hidden second data-query retry is allowed.
13. Execution Flow
The exact operation order is:
-
receive PDO, descriptor, request, config, and mapper
-
normalize page and per-page
-
resolve effective primary sort key and direction
-
execute `totalSql` with `totalParams`
-
validate total count
-
execute `filteredCountSql` with `filteredCountParams`
-
validate filtered count
-
calculate `totalPages`
-
apply zero-result and page-overflow policy
-
if filtered is zero, return without data execution
-
resolve and compose quoted primary sort and tie-breaker
-
calculate integer-safe offset
-
append `ORDER BY`, `LIMIT`, and `OFFSET`
-
prepare the final data statement
-
bind `dataParams`
-
bind internal limit and offset as integers
-
execute once
-
fetch associative rows
-
map and validate every item
-
return `PageResult`
14. Final SQL Assembly
With distinct primary and tie-breaker identifiers:
{dataSql}
ORDER BY {quoted_primary_identifier} {ASC|DESC},
{quoted_tie_breaker_identifier} {ASC|DESC}
LIMIT :__pagination_limit
OFFSET :__pagination_offset
With a duplicate resolved identifier:
{dataSql}
ORDER BY {quoted_primary_identifier} {ASC|DESC}
LIMIT :__pagination_limit
OFFSET :__pagination_offset
Only trusted whitelist output and enum directions enter ORDER BY.
15. Mapper Contract
Mapper PHPDoc:
callable(array<string, mixed> $row): array|object
The paginator explicitly fetches each row using PDO::FETCH_ASSOC.
Fetch failure rules for count and data statements:
- When fetch(PDO::FETCH_ASSOC) returns false, PDOStatement::errorCode() MUST be checked.
- The value '00000' indicates a normal end of results.
- Any other value is a non-throwing fetch failure and MUST convert to PaginationExecutionException.
- Partial success return is prohibited upon fetch failure.
- PDOException passes unchanged.
The mapper MAY return:
-
an array
-
a DTO or other object
The paginator appends mapped items to preserve list<T> semantics.
If the mapper returns any scalar or resource, throw PaginationExecutionException.
If the mapper itself throws any Throwable, rethrow the same instance unchanged.
Raw rows require an explicit identity mapper:
static fn (array $row): array => $row
16. Result Shape
PageResult::toArray() and jsonSerialize() return:
[
'data' => [],
'pagination' => [
'page' => 1,
'per_page' => 20,
'total' => 150,
'filtered' => 37,
'total_pages' => 2,
'has_next' => true,
'has_previous' => false,
'sort_by' => 'created_at',
'sort_direction' => 'DESC',
],
]
Rules:
-
`PageResult::toArray()` and `jsonSerialize()` return the same shallow envelope.
-
Mapped arrays are returned as is.
-
Mapped objects remain the exact same object instances inside `data`.
-
The package MUST NOT call `toArray()` or `jsonSerialize()` internally on the items and does not guarantee their serialization format.
-
`data` is always a list
-
no `offset` field
-
no raw invalid request values
-
no SQL identifiers or expressions exposed
-
`sortBy` is the effective public key
-
`sortDirection` is the effective primary enum direction
-
output field names are fixed
17. Transaction and Consistency Contract
The paginator does not own a transaction.
It MUST work:
-
without an active transaction
-
inside a caller-owned active transaction
It MUST NOT:
-
start a transaction
-
commit a transaction
-
roll back a transaction
-
reject an active transaction
The paginator guarantee is limited to never explicitly calling beginTransaction(), commit(), or rollBack(). External database/driver behavior and mapper-owned code may affect transaction state and remain outside the paginator guarantee. Documentation and tests MUST NOT claim that every possible external failure preserves transaction state.
Without a caller-owned consistent transaction, the three statements are independent reads. Concurrent changes may produce:
-
counts that differ from the number of returned rows
-
a page becoming empty after count
-
`filtered > total`
The paginator does not retry or silently change metadata to hide concurrent changes.
18. Exception Contract
All package-defined exceptions implement:
Maatify\Persistence\Exception\PersistenceException
18.1 InvalidPaginationConfigurationException
Base: Maatify\Exceptions\Exception\System\SystemMaatifyException
Error code: ErrorCodeEnum::MAATIFY_ERROR
Safety behavior: inherited `SystemMaatifyException` default
Triggered by:
-
invalid per-page bounds
-
default outside bounds
-
empty whitelist
-
invalid public key
-
invalid identifier path
-
missing default sort key
-
missing tie-breaker sort key
-
explicit lookup of an unknown whitelist key
Tie-breaker uniqueness remains a caller-owned semantic configuration guarantee. Version 1 does not promise Runtime detection or an exception for a non-unique database value set.
18.2 InvalidPaginationQueryException
Base: Maatify\Exceptions\Exception\System\SystemMaatifyException
Error code: ErrorCodeEnum::MAATIFY_ERROR
Safety behavior: inherited `SystemMaatifyException` default
Triggered by:
-
missing/empty SQL
-
any semicolon in SQL
-
invalid parameter key
-
leading-colon key
-
reserved `__pagination_` parameter-key or SQL-placeholder prefix collision
-
unsupported parameter value type
It classifies Runtime-checkable invalid trusted caller descriptors, not end-user validation.
General placeholder correspondence, repetition, positional-placeholder, and mixed-placeholder violations are caller contracts rather than guaranteed constructor classifications. They may surface through PDO during execution.
18.3 PaginationExecutionException
Base: Maatify\Exceptions\Exception\System\SystemMaatifyException
Error code: ErrorCodeEnum::MAATIFY_ERROR
Safety behavior: inherited `SystemMaatifyException` default
Triggered by package-owned execution classifications such as:
-
`PDO::prepare()` returns `false`
-
`PDOStatement::bindValue()` returns `false`
-
`PDOStatement::execute()` returns `false`
-
non-throwing fetch failure
-
invalid count result
-
invalid mapper result type
-
unexpected non-associative fetched row state
-
inconsistent `PageResult` state
18.4 Propagation Rules
-
Actual `PDOException` propagates unchanged.
-
Unknown external `Throwable` propagates unchanged.
-
Mapper-thrown `Throwable` propagates unchanged.
-
No blind catch-all wrapping.
-
No swallowed error.
-
No rollback attempt by the paginator.
Version 1 does not add a Pagination-specific marker interface.
19. Security and Trust Boundaries
Trusted application configuration:
-
SQL statements
-
SQL placeholder names
-
public-key-to-identifier whitelist
-
mandatory scopes embedded in caller SQL
-
mapper
Untrusted input:
-
page
-
per-page
-
sort key
-
sort direction
-
filter values passed through caller validation/binding
The paginator guarantees:
-
raw sort input is never SQL
-
directions come only from `SortDirectionEnum`
-
identifier paths are validated and quoted
-
runtime values are bound
-
internal pagination names cannot collide with caller parameter maps or caller SQL placeholder names
The paginator cannot guarantee:
-
authorization correctness of caller SQL
-
mandatory-scope alignment across three statements
-
correctness of filters or JOINs
-
SQL semantic equivalence
-
uniqueness of the caller-selected tie-breaker
-
general placeholder correspondence without parsing SQL
-
index quality or query performance
-
consistent snapshot without caller transaction
Omitting a mandatory security or tenant constraint from any descriptor statement is a caller security defect.
20. Backward Compatibility and Migration
Package-level effect:
-
additive classes only
-
no Ordering API changes
-
no existing Runtime behavior changes
-
no new framework dependency
-
no new Runtime package dependency
-
no `composer.lock`
Host migration rules:
-
implement and verify Pagination in `maatify/persistence`
-
publish only after full package review
-
migrate one low-risk host repository
-
preserve its endpoint contract or use a host adapter
-
review counts, sorting, overflow, and response compatibility
-
continue repository-by-repository
-
no mass migration without separate approval
The canonical fields are additive conceptually, but an endpoint response is not allowed to change silently merely because the package offers more metadata.
21. Required Tests Before Approval
Unit
-
all page and per-page normalization inputs
-
PHP integer boundary and overflow strings
-
sort-direction normalization
-
valid/invalid public keys
-
valid one/two/three-segment identifiers
-
forbidden expressions
-
identifier quoting
-
config invariants
-
descriptor SQL validation
-
exception trigger for any semicolon
-
parameter key/type validation
-
reserved `__pagination_` prefix collisions in parameter maps and SQL placeholder names
-
package-owned non-throwing `prepare()`, `bindValue()`, and `execute()` failure classifications
-
normal EOF vs non-throwing fetch failure
-
count fetch failure
-
data fetch failure after partial rows (no partial success)
-
direct valid `PageResult` construction
-
`PageResult` invariant rejection for non-list data, scalar items, oversized pages, zero-filtered non-empty data, invalid sort keys, inconsistent total pages, and inconsistent navigation flags
-
acceptance of empty data while `filtered > 0`
-
mapper not called when `filtered === 0`
-
shallow object identity in `toArray()` and `jsonSerialize()`
Regression
-
exact class names and namespaces
-
final/readonly modifiers
-
enum cases
-
constructor parameter names, order, types, and defaults
-
exact `filteredCountParams` property/parameter
-
`paginate()` parameter and return types
-
result field names and nesting
-
no offset field
-
exception bases, marker, and error codes
-
named-placeholder-only caller contract without a claimed general SQL parser
-
caller-owned tie-breaker uniqueness guarantee
-
exact renamed API check
-
exact final SQL assembly check
-
Ordering public API unchanged
Real MySQL Integration
-
total and filtered semantics
-
no-filter identical count statements
-
count results with exactly one row and one column
-
rejection of zero-row, multi-row, and multi-column count results
-
separate parameter maps
-
int/string/bool/null binding
-
internal integer limit/offset binding
-
native prepared statements
-
repeated, missing, unused, positional, and mixed placeholder violations surfacing through the documented PDO failure contract rather than constructor SQL parsing
-
default and requested sorting
-
invalid-sort fallback
-
duplicate primary values with a unique tie-breaker proving stable ordering
-
duplicate resolved primary/tie-breaker emitted once only when the resolved identifier is caller-guaranteed unique
-
zero filtered rows skip data execution
-
page overflow returns first page
-
mapper arrays and objects
-
mapper throwable propagation
-
invalid mapper result
-
PDO exception propagation
-
PDO attributes not changing
-
`filtered > total` scenario
-
successful operation inside caller transaction
-
caller transaction remains active after successful pagination
-
no claim that every external PDO/driver failure preserves transaction state
-
no transaction created outside caller transaction
-
repeated execution and fixture cleanup
SQLite is forbidden as a substitute for MySQL Integration proof.
22. Explicit Non-Goals for Version 1
-
cursor pagination
-
keyset pagination
-
multiple user sorts
-
comma-separated sorts
-
arbitrary SQL sort expressions
-
FilterWhitelist
-
SearchBuilder
-
QueryBuilder
-
ORM integration
-
automatic JOINs
-
automatic count optimization
-
approximate counts
-
caching
-
HTTP query parsing
-
HTTP response generation
-
Slim middleware
-
PSR-7 dependency
-
framework service providers
-
automatic SQL parser
-
automatic transaction ownership
-
automatic retry after concurrent changes
-
host repository migration inside the package implementation PR
23. Implementation Gate
Codex Runtime delivery for src/ is authorized only after explicit authorization.
Jules will execute complete tests, documentation, and compliance implementation in a separate phase after Runtime review.
Implementation delivery MUST:
-
match the exact Public API in section 6
-
keep all Ordering files behaviorally unchanged
-
avoid unrelated refactors
-
avoid documentation claims outside implemented behavior
-
create no `composer.lock`
24. Feature Acceptance Gate
The final implementation is accepted only when it passes:
-
Runtime
-
Unit / Regression / MySQL Integration
-
PHPStan
-
Package Reference
-
README
-
CHANGELOG
-
Composer metadata
Merge, tag creation, GitHub Release publication, and Packagist publication remain separate owner-controlled actions.
|