From 04a6cf8e8ed1a74a3fe3657d98861f37e103e256 Mon Sep 17 00:00:00 2001
From: Gustavo Freze ` paragraphs below the summary.
+- Tags use the form `@param Type $name Description.`, `@return Type Description.`,
+ `@throws ExceptionClass If ` for paragraphs, ` Operations between different currencies raise Sibling of {@see Quantity}, not a parent. When the schema is omitted, the canonical default key names and bounds apply. The
+ * pagination is a {@see CursorPagination} when the cursor key is present, otherwise an
+ * offset-based {@see Pagination}.` for inline code, `` and `` for emphasis.
+
+### Summary patterns
+
+The summary line is not a creative intent statement. It is a template selected by the method's
+name prefix. Apply the matching template. Only methods with no matching prefix require a
+free-form one-line summary in domain terms.
+
+| Method shape | Template |
+|-------------------------------------------------------------------------|--------------------------------------------------------------------------------|
+| Static factory (`create`, `from`, `fromX`, `with*` when static) | `Creates a {ClassName} from {input}.` or `Builds a {ClassName} with {fields}.` |
+| `with*` instance method | `Returns a copy of the {ClassName} with the {field} replaced.` |
+| Getter (no prefix, returns a property: `code()`, `body()`, `headers()`) | `Returns the {field}.` |
+| Predicate (`is*`, `has*`, `can*`, `was*`, `should*`) | `Tells whether {condition}.` |
+| Converter (`toArray`, `toString`, `asX`) | `Returns the {ClassName} as {target shape}.` |
+| `apply*`, `merge*`, `add*`, and other side-effect-free operations | One-line summary in domain terms describing the operation. |
+
+The patterns are mandatory when applicable. They make summary lines mechanical: substitute
+`{ClassName}` and `{field}` and the summary is complete. No per-method intent decision is
+required. Volume is never a reason to skip the summary. Many methods just mean applying the
+template many times.
+
+### Cross-references
+
+- `{@see ClassName}` for links to other types in the codebase.
+- `@see Author, Title (Publisher, Year), Chapter X.` for bibliographical references.
+
+### Examples
+
+**Prohibited.** Single-line bare-tag PHPDoc, no summary:
+
+```php
+/** @param arrayCurrencyMismatch. Arithmetic
+ * preserves the currency.Money carries currency semantics.$other has a different currency.
+ */
+ public function add(Money $other): Money;
+}
+```
+
+**Correct.** Concrete class with a short summary and direct tags:
+
+```php
+/**
+ * IANA timezone identifier (e.g. America/Sao_Paulo).
+ */
+final readonly class Timezone
+{
+ /**
+ * Creates a Timezone from a valid IANA identifier.
+ *
+ * @param string $identifier The IANA timezone identifier.
+ * @return Timezone The created instance.
+ * @throws InvalidTimezone If the identifier is not a valid IANA timezone.
+ */
+ public static function from(string $identifier): Timezone
+ {
+ # ...
+ }
+}
+```
+
+## Dependencies
+
+When the library needs an external dependency, prefer packages from the `tiny-blocks` ecosystem
+(https://github.com/tiny-blocks) whenever a suitable option exists. Reach for outside packages
+only when the ecosystem has no equivalent that fits the use case.
+
+## Collection usage
+
+When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array
+functions such as `array_map`, `array_filter`, `iterator_to_array`, or `foreach` plus accumulation.
+The same applies to `filter()`, `reduce()`, `each()`, and every other `Collectible` operation.
+Chain them fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*`
+function.
+
+**Prohibited.** `array_map` plus `iterator_to_array` on a `Collectible`:
+
+```php
+$names = array_map(
+ static fn(Element $element): string => $element->name(),
+ iterator_to_array($collection)
+);
+```
+
+**Correct.** Fluent chain with `map()` plus `toArray()`:
+
+```php
+$names = $collection
+ ->map(transformations: static fn(Element $element): string => $element->name())
+ ->toArray(keyPreservation: KeyPreservation::DISCARD);
+```
+
+## Format strings
+
+When building a message with placeholders, assign the format string to a `$template` variable
+first. Pass it to `sprintf` on a separate statement. The format and the data are visually
+separated, and the template line stays scannable.
+
+**Prohibited.** Format string inline with the call:
+
+```php
+if ($value < 0 || $value > 16) {
+ throw new PrecisionOutOfRange(
+ message: sprintf('Precision must be between 0 and 16, got %d.', $value)
+ );
+}
+```
+
+**Correct.** Format string in a `$template` variable:
+
+```php
+if ($value < 0 || $value > 16) {
+ $template = 'Precision must be between 0 and 16, got %d.';
+
+ throw new PrecisionOutOfRange(message: sprintf($template, $value));
+}
+```
+
+The `.` operator is never used to assemble a string. Value prefixes, value suffixes, inline
+fragments, and plain joins all go through `sprintf` with a `$template`. This holds even when
+no value is interpolated, for example when joining a directory and a file name.
+
+The sole exception is a placeholder-free `const` string literal that would exceed 120
+characters on a single line: it may use `.` to split across lines, since `sprintf` would
+not shorten the line and heredoc is unavailable in constant expressions.
+
+**Prohibited.** Concatenation to inject a value:
+
+```php
+$candidate = is_int($value) ? '@' . $value : $value;
+```
+
+**Correct.** `$template` plus `sprintf`:
+
+```php
+$template = '@%d';
+$candidate = is_int($value) ? sprintf($template, $value) : $value;
+```
+
+**Prohibited.** Concatenation to join strings:
+
+```php
+$location = $directory . '/' . $file;
+```
+
+**Correct.** A single `$template` for the join:
+
+```php
+$template = '%s/%s';
+$location = sprintf($template, $directory, $file);
+```
+
+## Constructor chaining
+
+PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in
+parentheses. The parentheses are no longer required and only add visual noise. Apply this
+everywhere a `new` is followed by a method call.
+
+**Prohibited.** Parentheses around the `new` expression:
+
+```php
+$body = (new ServerRequest(uri: 'https://api.example.com', method: 'GET'))
+ ->withHeader('Accept', 'application/json')
+ ->getBody();
+```
+
+**Correct.** No parentheses:
+
+```php
+$body = new ServerRequest(uri: 'https://api.example.com', method: 'GET')
+ ->withHeader('Accept', 'application/json')
+ ->getBody();
+```
+
+## Duplication
+
+When two or more places share logic, extract it into a collaborator (a value object, or a class
+in `src/Internal/`), or move it onto a collaborator both call sites already depend on. The type
+that owns the data owns the derived behavior.
+
+A shared base class is not available: inheritance between concrete classes is prohibited (see
+"Inheritance and constructors"). A shared private helper is not available either: private methods
+on public classes are prohibited (rule 13). Composition is therefore the only mechanism, and
+leaving the duplication in place is never the resolution.
+
+**Prohibited.** The same derivation copied byte for byte into two types:
+
+```php
+final readonly class Exam
+{
+ public function __construct(public int $score) {}
+
+ public function grade(): Grade
+ {
+ return match (true) {
+ $this->score >= 90 => Grade::A,
+ $this->score >= 80 => Grade::B,
+ $this->score >= 70 => Grade::C,
+ default => Grade::F
+ };
+ }
+}
+
+final readonly class Assignment
+{
+ public function __construct(public int $score) {}
+
+ public function grade(): Grade
+ {
+ return match (true) {
+ $this->score >= 90 => Grade::A,
+ $this->score >= 80 => Grade::B,
+ $this->score >= 70 => Grade::C,
+ default => Grade::F
+ };
+ }
+}
+```
+
+**Correct.** The derivation lives once on the collaborator both types hold, and each delegates:
+
+```php
+final readonly class Score
+{
+ public function __construct(public int $value) {}
+
+ public function toGrade(): Grade
+ {
+ return match (true) {
+ $this->value >= 90 => Grade::A,
+ $this->value >= 80 => Grade::B,
+ $this->value >= 70 => Grade::C,
+ default => Grade::F
+ };
+ }
+}
+
+final readonly class Exam
+{
+ public function __construct(public Score $score) {}
+
+ public function grade(): Grade
+ {
+ return $this->score->toGrade();
+ }
+}
+
+final readonly class Assignment
+{
+ public function __construct(public Score $score) {}
+
+ public function grade(): Grade
+ {
+ return $this->score->toGrade();
+ }
+}
+```
+
+## Polymorphism and tell-don't-ask
+
+This refines rules 9 and 24. A `match` on an enum, on a scalar, or on a value condition stays
+correct. What is prohibited is branching on the runtime type of polymorphic collaborator the
+library defines: when behavior differs across the concrete implementations of an interface the
+library owns, that behavior is a method on the interface, resolved by the object itself, never an
+`instanceof` or `get_class` chain at the call site.
+
+The opening sentence holds only for control flow. When a branch on an enum case yields a value or
+behavior that belongs to the case itself, a token, a flag about the case's nature, or a derived
+value, that value or behavior is a method on the enum: a predicate `isXxx()`, or a vocabulary
+method that returns the value, called at the site instead of comparing the case. Comparing a case
+(`$direction === Order::ASCENDING`, `match ($direction)`) stays correct for control flow whose
+outcome is not a property of the case. This is the enum form of tell-don't-ask, and the companion
+of the modeling rule that enums carry methods only when those methods hold vocabulary meaning (see
+`php-library-modeling.md`, "Enums"): a case that drives a derived value is exactly that vocabulary.
+
+A consumer is outside this rule. A consumer matching on a sealed type the library exposes (for
+example, translating a parsed tree into its own store) cannot add methods to the library's types,
+so its `instanceof` is legitimate. The rule binds the library's own code.
+
+A type the library owns may `instanceof` its own internal types at construction or registration
+time, to invoke behavior that exists only on the concrete type and that cannot be lifted onto a
+public extension interface without breaking external implementers. The minimal public interface
+outweighs the local, build-time type check.
+
+Tell-don't-ask. Behavior that depends on a collaborator's state belongs to the collaborator. Do
+not read a collaborator's fields to recompute a result the collaborator should produce. Ask it for
+the result, not for its parts. A getter exposes a value the caller needs as data, it is not a
+license to reimplement the collaborator's logic at the call site. Tell-don't-ask binds the types
+the library owns. Reading a value off a type the library does not own (a dependency's value object,
+a PSR type) and computing with it is interop, not a violation: the library cannot add a method to a
+type it does not control. The rule still binds the library's own types.
+
+**Prohibited.** Dispatching on the concrete type of interface the library owns:
+
+```php
+return match (true) {
+ $discount instanceof Percentage => $amount->multiplyBy(factor: $discount->rate()),
+ $discount instanceof Fixed => $amount->subtract(other: $discount->amount())
+};
+```
+
+**Correct.** The behavior is a method on the interface, resolved by the object:
+
+```php
+return $discount->applyTo(amount: $amount);
+```
+
+**Prohibited.** Comparing an enum case to produce a value the case owns:
+
+```php
+$token = match ($direction) {
+ Order::ASCENDING => '',
+ Order::DESCENDING => '-'
+};
+```
+
+**Correct.** A vocabulary method on the enum returns the value, called at the site:
+
+```php
+enum Order: string
+{
+ case ASCENDING = 'asc';
+ case DESCENDING = 'desc';
+
+ public function token(): string
+ {
+ return match ($this) {
+ self::ASCENDING => '',
+ self::DESCENDING => '-'
+ };
+ }
+}
+
+$token = $direction->token();
+```
+
+**Prohibited.** Reading a collaborator's parts to recompute what it already owns:
+
+```php
+$doubled = Money::of(amount: $price->amount() * 2, currency: $price->currency());
+```
+
+**Correct.** Telling the collaborator to produce the result:
+
+```php
+$doubled = $price->multiplyBy(factor: 2);
+```
+
+## Return statements
+
+A method has at most three `return` statements. The cap keeps methods small and their control
+flow scannable, and it complements rule 9: early returns are the preferred alternative to `else`,
+but they stop being a simplification once a method accumulates more than three exit points.
+Invariant violations are signaled with a `throw`, not a `return`, so guard clauses usually do not
+add to the count.
+
+**Prohibited.** Four return points:
+
+```php
+public function classify(int $score): Grade
+{
+ if ($score >= 90) {
+ return Grade::A;
+ }
+
+ if ($score >= 80) {
+ return Grade::B;
+ }
+
+ if ($score >= 70) {
+ return Grade::C;
+ }
+
+ return Grade::F;
+}
+```
+
+**Correct.** Single return through `match`:
+
+```php
+public function classify(int $score): Grade
+{
+ return match (true) {
+ $score >= 90 => Grade::A,
+ $score >= 80 => Grade::B,
+ $score >= 70 => Grade::C,
+ default => Grade::F
+ };
+}
+```
+
+## Formatting overrides
+
+Four formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12`
+only). Apply them manually.
+
+### Single-line signatures within 120 characters
+
+A function or constructor signature stays on one line when the whole signature fits within the
+120-character limit. Do not break the parameter list onto multiple lines unless the single-line
+form would exceed 120 characters. The opening brace still goes on its own line (PSR-12). Break to
+one parameter per line only when the signature genuinely overflows.
+
+**Prohibited.** Multiline signature that fits on one line:
+
+```php
+private function __construct(
+ public ExternalReference $id,
+ public Money $amount,
+ public OrderContext $context
+) {
+}
+```
+
+**Correct.** Single line within 120 characters:
+
+```php
+private function __construct(public ExternalReference $id, public Money $amount, public OrderContext $context)
+{
+}
+```
+
+When the one-line form would exceed 120 characters, break to one parameter per line and apply the
+no-vertical-alignment and no-trailing-comma rules below.
+
+### No vertical alignment in parameter lists
+
+Use a single space between the type and the variable name in parameter lists (constructors,
+function signatures, closures). Never pad with extra spaces to align columns. This rule applies
+only to parameter lists, not to other contexts that use `=>` alignment (see "Vertical alignment
+of `=>`" below).
+
+**Prohibited.** Vertical alignment of types:
+
+```php
+public function __construct(
+ public OrderId $id,
+ public Money $total,
+ public Customer $customer,
+ public Precision $precision
+) {}
+```
+
+**Correct.** Single space between type and variable:
+
+```php
+public function __construct(
+ public OrderId $id,
+ public Money $total,
+ public Customer $customer,
+ public Precision $precision
+) {}
+```
+
+### Vertical alignment of `=>` in match arms and array literals
+
+Multi-line `match` expressions and multi-line array literals with `=>` align the `=>` column
+across all arms or entries by padding shorter left-hand sides with spaces. Single-line cases
+(one-arm match, single-line array) keep the standard PSR-12 single-space form.
+
+**Prohibited.** Unaligned `=>` in match:
+
+```php
+return match ($this) {
+ self::MAX_AGE => sprintf($template, $this->value, $value),
+ default => $this->value
+};
+```
+
+**Correct.** Aligned `=>` in match:
+
+```php
+return match ($this) {
+ self::MAX_AGE => sprintf($template, $this->value, $value),
+ default => $this->value
+};
+```
+
+**Prohibited.** Unaligned `=>` in array literal:
+
+```php
+return [
+ 'name' => 'Gustavo',
+ 'role' => 'developer',
+ 'company' => 'Anthropic'
+];
+```
+
+**Correct.** Aligned `=>` in array literal:
+
+```php
+return [
+ 'name' => 'Gustavo',
+ 'role' => 'developer',
+ 'company' => 'Anthropic'
+];
+```
+
+### No trailing comma in multi-line lists
+
+Never place a trailing comma after the last element of any multi-line list. Applies to parameter
+lists, argument lists, array literals, match arms, and every other comma-separated multi-line
+structure. PHP accepts trailing commas in these positions, but this ecosystem prohibits them for
+visual consistency.
+
+**Prohibited.** Trailing comma after the last argument:
+
+```php
+new Precision(
+ value: 2,
+ rounding: RoundingMode::HALF_UP,
+);
+```
+
+**Correct.** No trailing comma:
+
+```php
+new Precision(
+ value: 2,
+ rounding: RoundingMode::HALF_UP
+);
+```
diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md
new file mode 100644
index 0000000..a29de61
--- /dev/null
+++ b/.claude/rules/php-library-documentation.md
@@ -0,0 +1,203 @@
+---
+description: Conventions for README and public-facing Markdown docs in PHP libraries.
+paths:
+ - "README.md"
+ - "docs/**/*.md"
+---
+
+# Documentation
+
+Conventions for `README.md` and the public-facing Markdown a library ships. PHPDoc rules for
+`.php` files live in `php-library-code-style.md`. American English applies everywhere (see the
+American English section in `php-library-code-style.md`).
+
+The **canonical bodies** of the non-README repository files (`SECURITY.md`, the issue templates,
+the pull request template) are not duplicated here. They live as drop-in assets in the
+`tiny-blocks-create` skill, the single source of truth for those files. This rule governs how
+the README and any `docs/` Markdown are written. "Required repository files" below lists which
+files must exist and points to the skill for their content.
+
+`CONTRIBUTING.md` is centralized at
+`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and
+pull request template link to that location. No local `CONTRIBUTING.md` is created per library.
+
+## Pre-output checklist
+
+Verify every item before producing any Markdown documentation. If any item fails, revise before
+outputting.
+
+1. README title is `#
The consumer fetches one element beyond the page size via keyset. The extra element is + * trimmed and its presence is read as the next-page hint. The forward cursor anchors on the + * last retained element, the backward cursor on the first.
+ * + * @param Collection;) binds tighter than the OR token (,).
+ *
+ * @see https://github.com/jirutka/rsql-parser
+ */
+enum LogicalOperator: string
+{
+ case OR = ',';
+ case AND = ';';
+
+ /**
+ * Tells whether this connective binds tighter than the given one.
+ *
+ * @param LogicalOperator $other The connective to compare against.
+ * @return bool True when this connective binds tighter.
+ */
+ public function bindsTighterThan(LogicalOperator $other): bool
+ {
+ return $this === LogicalOperator::AND && $other === LogicalOperator::OR;
+ }
+}
diff --git a/src/Navigation.php b/src/Navigation.php
new file mode 100644
index 0000000..2502c04
--- /dev/null
+++ b/src/Navigation.php
@@ -0,0 +1,65 @@
+A result lists its own targets, so {@see Links} renders every result uniformly without
+ * branching on the concrete result type. The insertion order is preserved.
+ */
+final readonly class Navigation
+{
+ /**
+ * @param CollectionA null target is ignored, so the copy carries the same targets as the original.
+ * + * @param Paging|null $target The paging the target points to, or null to ignore. + * @param LinkRelation $relation The relation the target is reached through. + * @return Navigation A copy carrying the original targets plus the added target. + */ + public function with(?Paging $target, LinkRelation $relation): Navigation + { + if (is_null($target)) { + return $this; + } + + return new Navigation(targets: $this->targets->add(NavigationTarget::to(target: $target, relation: $relation))); + } + + /** + * Returns the navigation targets in insertion order. + * + * @return Collectionfilter, sort,
+ * page, per_page, and cursor, with a default page size of
+ * 20 and a maximum of 100.
+ */
+final readonly class Schema
+{
+ private function __construct(
+ private string $pageKey,
+ private string $sortKey,
+ private string $cursorKey,
+ private string $filterKey,
+ private int $maxPerPage,
+ private string $perPageKey,
+ private int $defaultPerPage
+ ) {
+ if ($maxPerPage < 1) {
+ throw PageSizeOutOfRange::belowMinimum(perPage: $maxPerPage);
+ }
+
+ if ($defaultPerPage < 1) {
+ throw PageSizeOutOfRange::belowMinimum(perPage: $defaultPerPage);
+ }
+
+ if ($defaultPerPage > $maxPerPage) {
+ throw PageSizeOutOfRange::aboveMaximum(maximum: $maxPerPage, perPage: $defaultPerPage);
+ }
+ }
+
+ /**
+ * Creates a Schema carrying the canonical default key names and bounds.
+ *
+ * @return Schema The default schema.
+ */
+ public static function default(): Schema
+ {
+ return new Schema(
+ pageKey: 'page',
+ sortKey: 'sort',
+ cursorKey: 'cursor',
+ filterKey: 'filter',
+ maxPerPage: 100,
+ perPageKey: 'per_page',
+ defaultPerPage: 20
+ );
+ }
+
+ /**
+ * Returns the query key carrying the page number.
+ *
+ * @return string The page key.
+ */
+ public function pageKey(): string
+ {
+ return $this->pageKey;
+ }
+
+ /**
+ * Returns the query key carrying the sort expression.
+ *
+ * @return string The sort key.
+ */
+ public function sortKey(): string
+ {
+ return $this->sortKey;
+ }
+
+ /**
+ * Returns the query key carrying the cursor token.
+ *
+ * @return string The cursor key.
+ */
+ public function cursorKey(): string
+ {
+ return $this->cursorKey;
+ }
+
+ /**
+ * Returns the query key carrying the RSQL filter.
+ *
+ * @return string The filter key.
+ */
+ public function filterKey(): string
+ {
+ return $this->filterKey;
+ }
+
+ /**
+ * Returns the maximum allowed page size.
+ *
+ * @return int The maximum page size.
+ */
+ public function maxPerPage(): int
+ {
+ return $this->maxPerPage;
+ }
+
+ /**
+ * Returns the query key carrying the page size.
+ *
+ * @return string The page-size key.
+ */
+ public function perPageKey(): string
+ {
+ return $this->perPageKey;
+ }
+
+ /**
+ * Returns the requested page size bounded by the maximum allowed.
+ *
+ * @param int $requested The requested page size.
+ * @return int The requested page size when it is within the maximum.
+ * @throws PageSizeOutOfRange If the requested size exceeds the maximum.
+ */
+ public function pageSizeFor(int $requested): int
+ {
+ if ($requested > $this->maxPerPage) {
+ throw PageSizeOutOfRange::aboveMaximum(maximum: $this->maxPerPage, perPage: $requested);
+ }
+
+ return $requested;
+ }
+
+ /**
+ * Returns a copy of the Schema with the page key replaced.
+ *
+ * @param string $pageKey The query key carrying the page number.
+ * @return Schema A copy of the schema carrying the new page key.
+ */
+ public function withPageKey(string $pageKey): Schema
+ {
+ return new Schema(
+ pageKey: $pageKey,
+ sortKey: $this->sortKey,
+ cursorKey: $this->cursorKey,
+ filterKey: $this->filterKey,
+ maxPerPage: $this->maxPerPage,
+ perPageKey: $this->perPageKey,
+ defaultPerPage: $this->defaultPerPage
+ );
+ }
+
+ /**
+ * Returns a copy of the Schema with the sort key replaced.
+ *
+ * @param string $sortKey The query key carrying the sort expression.
+ * @return Schema A copy of the schema carrying the new sort key.
+ */
+ public function withSortKey(string $sortKey): Schema
+ {
+ return new Schema(
+ pageKey: $this->pageKey,
+ sortKey: $sortKey,
+ cursorKey: $this->cursorKey,
+ filterKey: $this->filterKey,
+ maxPerPage: $this->maxPerPage,
+ perPageKey: $this->perPageKey,
+ defaultPerPage: $this->defaultPerPage
+ );
+ }
+
+ /**
+ * Returns a copy of the Schema with the cursor key replaced.
+ *
+ * @param string $cursorKey The query key carrying the cursor token.
+ * @return Schema A copy of the schema carrying the new cursor key.
+ */
+ public function withCursorKey(string $cursorKey): Schema
+ {
+ return new Schema(
+ pageKey: $this->pageKey,
+ sortKey: $this->sortKey,
+ cursorKey: $cursorKey,
+ filterKey: $this->filterKey,
+ maxPerPage: $this->maxPerPage,
+ perPageKey: $this->perPageKey,
+ defaultPerPage: $this->defaultPerPage
+ );
+ }
+
+ /**
+ * Returns a copy of the Schema with the filter key replaced.
+ *
+ * @param string $filterKey The query key carrying the RSQL filter.
+ * @return Schema A copy of the schema carrying the new filter key.
+ */
+ public function withFilterKey(string $filterKey): Schema
+ {
+ return new Schema(
+ pageKey: $this->pageKey,
+ sortKey: $this->sortKey,
+ cursorKey: $this->cursorKey,
+ filterKey: $filterKey,
+ maxPerPage: $this->maxPerPage,
+ perPageKey: $this->perPageKey,
+ defaultPerPage: $this->defaultPerPage
+ );
+ }
+
+ /**
+ * Returns the page size applied when the query omits one.
+ *
+ * @return int The default page size.
+ */
+ public function defaultPerPage(): int
+ {
+ return $this->defaultPerPage;
+ }
+
+ /**
+ * Returns a copy of the Schema with the maximum page size replaced.
+ *
+ * @param int $maxPerPage The maximum allowed page size.
+ * @return Schema A copy of the schema carrying the new maximum page size.
+ */
+ public function withMaxPerPage(int $maxPerPage): Schema
+ {
+ return new Schema(
+ pageKey: $this->pageKey,
+ sortKey: $this->sortKey,
+ cursorKey: $this->cursorKey,
+ filterKey: $this->filterKey,
+ maxPerPage: $maxPerPage,
+ perPageKey: $this->perPageKey,
+ defaultPerPage: $this->defaultPerPage
+ );
+ }
+
+ /**
+ * Returns a copy of the Schema with the page-size key replaced.
+ *
+ * @param string $perPageKey The query key carrying the page size.
+ * @return Schema A copy of the schema carrying the new page-size key.
+ */
+ public function withPerPageKey(string $perPageKey): Schema
+ {
+ return new Schema(
+ pageKey: $this->pageKey,
+ sortKey: $this->sortKey,
+ cursorKey: $this->cursorKey,
+ filterKey: $this->filterKey,
+ maxPerPage: $this->maxPerPage,
+ perPageKey: $perPageKey,
+ defaultPerPage: $this->defaultPerPage
+ );
+ }
+
+ /**
+ * Returns a copy of the Schema with the default page size replaced.
+ *
+ * @param int $defaultPerPage The page size applied when the query omits one.
+ * @return Schema A copy of the schema carrying the new default page size.
+ */
+ public function withDefaultPerPage(int $defaultPerPage): Schema
+ {
+ return new Schema(
+ pageKey: $this->pageKey,
+ sortKey: $this->sortKey,
+ cursorKey: $this->cursorKey,
+ filterKey: $this->filterKey,
+ maxPerPage: $this->maxPerPage,
+ perPageKey: $this->perPageKey,
+ defaultPerPage: $defaultPerPage
+ );
+ }
+}
diff --git a/src/Slice.php b/src/Slice.php
new file mode 100644
index 0000000..d072b2b
--- /dev/null
+++ b/src/Slice.php
@@ -0,0 +1,141 @@
+ $items
+ */
+ private function __construct(private Collection $items, private bool $hasNext, private Pagination $pagination)
+ {
+ }
+
+ /**
+ * Creates a Slice from the items and the pagination.
+ *
+ * The consumer fetches one element beyond the page size. The extra element is trimmed and + * its presence is read as the next-page hint.
+ * + * @param Collection-created_at,id. The request order is preserved.
+ */
+final readonly class Sort
+{
+ private function __construct(private array $orders)
+ {
+ }
+
+ /**
+ * Creates a Sort from a comma-separated sort expression.
+ *
+ * An empty expression yields an empty Sort. A leading minus on a field marks descending + * order, every other field is ascending.
+ * + * @param string $expression The comma-separated sort expression. + * @return Sort The parsed ordering with the request order preserved. + * @throws SortExpressionIsInvalid If a field is empty or carries reserved characters. + */ + public static function fromExpression(string $expression): Sort + { + $trimmed = trim($expression); + + if ($trimmed === '') { + return new Sort(orders: []); + } + + $orders = array_map(static function (string $token) use ($expression): Order { + $descending = str_starts_with($token, '-'); + $field = $descending ? substr($token, 1) : $token; + + if (preg_match('/^\w[\w.-]*$/', $field) !== 1) { + throw SortExpressionIsInvalid::from(expression: $expression); + } + + $direction = $descending ? Direction::DESCENDING : Direction::ASCENDING; + + return Order::from(field: $field, direction: $direction); + }, explode(',', $trimmed)); + + return new Sort(orders: $orders); + } + + /** + * Returns the orders in the request order. + * + * @return listIt parses from the request query string and serializes back to a URI, so the navigation links - * built from it preserve the filter and the sort.
+ * built from it preserve the filter and the sort. It also builds the result page for the items the + * store returns, keeping the pagination style internal. */ final readonly class Criteria { private function __construct( + private PageParameters $page, private Sort $sort, private Filter $filter, - private Schema $schema, - private Paging $pagination + private Pagination $pagination ) { } /** - * Creates a Criteria from the request query parameters and an optional schema. + * Creates a Criteria from the request and an optional schema. * - *When the schema is omitted, the canonical default key names and bounds apply. The - * pagination is a {@see CursorPagination} when the cursor key is present, otherwise an - * offset-based {@see Pagination}.
+ *When the schema is omitted, the default page-size bounds apply. The pagination is a + * {@see CursorPagination} when the cursor is present, otherwise an {@see OffsetPagination}.
* - * @param QueryParameters $query The decoded request query parameters. - * @param Schema|null $schema The schema mapping the query keys and bounds, or null for the default. + * @param ServerRequestInterface $request The incoming PSR-7 server request. + * @param Schema|null $schema The schema carrying the page-size bounds, or null for the default. * @return Criteria The criteria carrying the parsed filter, sort, and pagination. * @throws FilterExpressionIsInvalid If the filter expression cannot be parsed. * @throws SortExpressionIsInvalid If the sort expression cannot be parsed. @@ -45,16 +48,17 @@ private function __construct( * @throws PageSizeOutOfRange If the page size falls outside the valid range. * @throws OffsetOutOfRange If the offset is less than 0. */ - public static function fromQuery(QueryParameters $query, ?Schema $schema = null): Criteria + public static function fromQuery(ServerRequestInterface $request, ?Schema $schema = null): Criteria { $schema = $schema ?? Schema::default(); - $expression = $query->get(key: $schema->filterKey())->toString(); - $filter = $expression === '' ? Group::none() : FilterParser::from(input: $expression)->parse(); + $query = QueryParameters::from(request: $request); + $page = PageParameters::from(query: $query, schema: $schema); - $sort = Sort::fromExpression(expression: $query->get(key: $schema->sortKey())->toString()); - $pagination = PaginationResolver::from(query: $query, schema: $schema)->resolve(); + $expression = $query->get(key: 'filter')->toString(); + $filter = $expression === '' ? Group::none() : FilterParser::from(input: $expression)->parse(); + $sort = Sort::fromExpression(expression: $query->get(key: 'sort')->toString()); - return new Criteria(sort: $sort, filter: $filter, schema: $schema, pagination: $pagination); + return new Criteria(page: $page, sort: $sort, filter: $filter, pagination: $page->resolve()); } /** @@ -66,17 +70,18 @@ public static function fromQuery(QueryParameters $query, ?Schema $schema = null) public function toUri(string $baseUri): string { $parameters = []; - $template = '%s=%s'; if (!$this->filter->isEmpty()) { - $parameters[] = sprintf($template, $this->schema->filterKey(), $this->filter->toExpression()->value()); + $template = 'filter=%s'; + $parameters[] = sprintf($template, $this->filter->toExpression()->value()); } if (!$this->sort->isEmpty()) { - $parameters[] = sprintf($template, $this->schema->sortKey(), $this->sort->toExpression()); + $template = 'sort=%s'; + $parameters[] = sprintf($template, $this->sort->toExpression()); } - $parameters[] = $this->pagination->toQueryString(schema: $this->schema); + $parameters[] = $this->pagination->toQueryString(); $template = '%s?%s'; return sprintf($template, $baseUri, implode('&', $parameters)); @@ -102,24 +107,68 @@ public function filtering(): Filter return $this->filter; } + /** + * Creates a CursorPage from the items and the ordering-key extractor. + * + * @template TValue + * @param iterableThe consumer fetches one element beyond the page size via keyset. The extra element is * trimmed and its presence is read as the next-page hint. The forward cursor anchors on the * last retained element, the backward cursor on the first.
* - * @param CollectionA null target is ignored, so the copy carries the same targets as the original.
* - * @param Paging|null $target The paging the target points to, or null to ignore. + * @param Pagination|null $target The paging the target points to, or null to ignore. * @param LinkRelation $relation The relation the target is reached through. * @return Navigation A copy carrying the original targets plus the added target. */ - public function with(?Paging $target, LinkRelation $relation): Navigation + public function with(?Pagination $target, LinkRelation $relation): Navigation { if (is_null($target)) { return $this; diff --git a/src/NavigationTarget.php b/src/NavigationTarget.php index f3b5155..5bac6c8 100644 --- a/src/NavigationTarget.php +++ b/src/NavigationTarget.php @@ -11,18 +11,18 @@ */ final readonly class NavigationTarget { - private function __construct(private Paging $target, private LinkRelation $relation) + private function __construct(private Pagination $target, private LinkRelation $relation) { } /** * Creates a NavigationTarget from a paging target and the relation it carries. * - * @param Paging $target The paging the target points to. + * @param Pagination $target The paging the target points to. * @param LinkRelation $relation The relation the target is reached through. * @return NavigationTarget The composed navigation target. */ - public static function to(Paging $target, LinkRelation $relation): NavigationTarget + public static function to(Pagination $target, LinkRelation $relation): NavigationTarget { return new NavigationTarget(target: $target, relation: $relation); } @@ -30,9 +30,9 @@ public static function to(Paging $target, LinkRelation $relation): NavigationTar /** * Returns the paging the target points to. * - * @return Paging The paging target. + * @return Pagination The paging target. */ - public function target(): Paging + public function target(): Pagination { return $this->target; } diff --git a/src/OffsetPage.php b/src/OffsetPage.php new file mode 100644 index 0000000..caa519e --- /dev/null +++ b/src/OffsetPage.php @@ -0,0 +1,252 @@ + $items + */ + private function __construct( + private Collection $items, + private Total $total, + private Criteria $criteria, + private OffsetNavigator $navigator, + private PageCount $pageCount, + private PageNumber $pageNumber, + private OffsetPagination $pagination + ) { + } + + /** + * Creates an OffsetPage from the items, the total element count, the criteria, and the pagination. + * + * @template TElement + * @param iterableThe consumer fetches one element beyond the page size. The extra element is trimmed and + * its presence is read as the next-page hint.
+ * + * @template TElement + * @param iterableThe page-based factory derives the offset from the one-based page number and the page size.
+ *It is implemented by the offset-based {@see OffsetPagination} and the keyset {@see CursorPagination}, + * so a {@see Criteria} carries either one without branching on the concrete type.
*/ -final readonly class Pagination implements Paging +interface Pagination { - private function __construct(private int $offset, private int $limit) - { - } - - /** - * Creates a Pagination from a one-based page number and a page size. - * - * @param int $page The one-based page number. - * @param int $perPage The page size. - * @return Pagination The pagination whose offset is derived from the page and the page size. - * @throws PageNumberOutOfRange If the page number is less than 1. - * @throws PageSizeOutOfRange If the page size is less than 1. - */ - public static function fromPage(int $page, int $perPage): Pagination - { - if ($page < 1) { - throw PageNumberOutOfRange::from(page: $page); - } - - if ($perPage < 1) { - throw PageSizeOutOfRange::belowMinimum(perPage: $perPage); - } - - return new Pagination(offset: ($page - 1) * $perPage, limit: $perPage); - } - - /** - * Creates a Pagination from a zero-based offset and a limit. - * - * @param int $offset The zero-based offset. - * @param int $limit The maximum number of elements per page. - * @return Pagination The pagination carrying the offset and the limit. - * @throws OffsetOutOfRange If the offset is less than 0. - * @throws PageSizeOutOfRange If the limit is less than 1. - */ - public static function fromOffset(int $offset, int $limit): Pagination - { - if ($offset < 0) { - throw OffsetOutOfRange::from(offset: $offset); - } - - if ($limit < 1) { - throw PageSizeOutOfRange::belowMinimum(perPage: $limit); - } - - return new Pagination(offset: $offset, limit: $limit); - } - - /** - * Returns the pagination for the next page. - * - * @return Pagination The pagination for the next page. - */ - public function next(): Pagination - { - return Pagination::fromPage(page: $this->page() + 1, perPage: $this->limit); - } - - /** - * Returns the one-based page number derived from the offset and the limit. - * - * @return int The one-based page number. - */ - public function page(): int - { - return intdiv($this->offset, $this->limit) + 1; - } - - /** - * Returns the pagination for the first page. - * - * @return Pagination The pagination for the first page. - */ - public function first(): Pagination - { - return Pagination::fromPage(page: 1, perPage: $this->limit); - } - - public function limit(): int - { - return $this->limit; - } - /** - * Returns the pagination for the given page. + * Returns the maximum number of elements per page. * - * @param int $page The one-based page number. - * @return Pagination The pagination for the given page. - * @throws PageNumberOutOfRange If the page number is less than 1. + * @return int The limit. */ - public function atPage(int $page): Pagination - { - return Pagination::fromPage(page: $page, perPage: $this->limit); - } + public function limit(): int; /** - * Returns the zero-based offset. + * Returns the pagination as a JSON:API query string fragment. * - * @return int The offset. + * @return string The query string fragment carrying the pagination. */ - public function offset(): int - { - return $this->offset; - } - - /** - * Returns the pagination for the previous page, or null when there is none. - * - * @return Pagination|null The pagination for the previous page, or null. - */ - public function previous(): ?Pagination - { - if (!$this->hasPrevious()) { - return null; - } - - return Pagination::fromPage(page: $this->page() - 1, perPage: $this->limit); - } - - /** - * Tells whether a previous page exists. - * - * @return bool True when a previous page exists. - */ - public function hasPrevious(): bool - { - return $this->page() > 1; - } - - public function toQueryString(Schema $schema): string - { - $template = '%s=%d&%s=%d'; - - return sprintf($template, $schema->pageKey(), $this->page(), $schema->perPageKey(), $this->limit); - } + public function toQueryString(): string; } diff --git a/src/Paging.php b/src/Paging.php deleted file mode 100644 index be6bf90..0000000 --- a/src/Paging.php +++ /dev/null @@ -1,29 +0,0 @@ -It is implemented by the offset-based {@see Pagination} and the keyset {@see CursorPagination}, - * so a {@see Criteria} carries either one without branching on the concrete type. - */ -interface Paging -{ - /** - * Returns the maximum number of elements per page. - * - * @return int The limit. - */ - public function limit(): int; - - /** - * Returns the pagination as a query string fragment built against the schema keys. - * - * @param Schema $schema The schema mapping the query key names. - * @return string The query string fragment carrying the pagination. - */ - public function toQueryString(Schema $schema): string; -} diff --git a/src/Schema.php b/src/Schema.php index b479bdc..a7dd2d1 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -7,23 +7,15 @@ use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange; /** - * Configurable mapping of query parameter names and page-size bounds used to read and write a Criteria. + * Page-size bounds used to read and write a Criteria. * - *The defaults follow the canonical conventions: filter, sort,
- * page, per_page, and cursor, with a default page size of
- * 20 and a maximum of 100.
The query parameter names follow JSON:API and are fixed: filter, sort,
+ * and the page family. The default page size is 20 and the maximum is 100.
The consumer fetches one element beyond the page size. The extra element is trimmed and - * its presence is read as the next-page hint.
- * - * @param CollectionIt parses from the request query string and serializes back to a URI, so the navigation links - * built from it preserve the filter and the sort. It also builds the result page for the items the - * store returns, keeping the pagination style internal.
+ *It parses from the request query string and builds the result page for the items the store + * returns, keeping the pagination style internal.
*/ final readonly class Criteria { - private function __construct( - private PageParameters $page, - private Sort $sort, - private Filter $filter, - private Pagination $pagination - ) { + private function __construct(private PageParameters $page, private Sort $sort, private Filter $filter) + { } /** @@ -58,33 +53,7 @@ public static function fromQuery(ServerRequestInterface $request, ?Schema $schem $filter = $expression === '' ? Group::none() : FilterParser::from(input: $expression)->parse(); $sort = Sort::fromExpression(expression: $query->get(key: 'sort')->toString()); - return new Criteria(page: $page, sort: $sort, filter: $filter, pagination: $page->resolve()); - } - - /** - * Returns the criteria as a URI built on the given base. - * - * @param string $baseUri The base URI the query string is appended to. - * @return string The URI carrying the filter, sort, and pagination. - */ - public function toUri(string $baseUri): string - { - $parameters = []; - - if (!$this->filter->isEmpty()) { - $template = 'filter=%s'; - $parameters[] = sprintf($template, $this->filter->toExpression()->value()); - } - - if (!$this->sort->isEmpty()) { - $template = 'sort=%s'; - $parameters[] = sprintf($template, $this->sort->toExpression()); - } - - $parameters[] = $this->pagination->toQueryString(); - $template = '%s?%s'; - - return sprintf($template, $baseUri, implode('&', $parameters)); + return new Criteria(page: $page, sort: $sort, filter: $filter); } /** @@ -144,7 +113,7 @@ public function offsetPage(iterable $items, int $total): OffsetPage */ public function pagination(): Pagination { - return $this->pagination; + return $this->page->resolve(); } /** @@ -160,15 +129,4 @@ public function offsetSlice(iterable $items): OffsetSlice { return OffsetSlice::from(items: $items, criteria: $this, pagination: $this->page->toOffset()); } - - /** - * Returns a copy of the criteria with the pagination replaced. - * - * @param Pagination $pagination The pagination to apply. - * @return Criteria A new criteria carrying the given pagination with the filter and sort preserved. - */ - public function withPagination(Pagination $pagination): Criteria - { - return new Criteria(page: $this->page, sort: $this->sort, filter: $this->filter, pagination: $pagination); - } } diff --git a/src/Cursor.php b/src/Cursor.php index 8d299e8..0f24434 100644 --- a/src/Cursor.php +++ b/src/Cursor.php @@ -5,7 +5,7 @@ namespace TinyBlocks\HttpQuery; use TinyBlocks\HttpQuery\Exceptions\CursorIsInvalid; -use TinyBlocks\HttpQuery\Internal\CursorCodec; +use TinyBlocks\HttpQuery\Internal\Cursor\CursorCodec; /** * Opaque, URI-safe token wrapping the ordering key values of a keyset (cursor) page. diff --git a/src/CursorPage.php b/src/CursorPage.php index 2f6f0c2..d625097 100644 --- a/src/CursorPage.php +++ b/src/CursorPage.php @@ -9,7 +9,7 @@ use TinyBlocks\Collection\Collection; use TinyBlocks\Http\LinkRelation; use TinyBlocks\Http\Server\Response; -use TinyBlocks\HttpQuery\Internal\Keyset; +use TinyBlocks\HttpQuery\Internal\Cursor\Keyset; use TinyBlocks\HttpQuery\Internal\Limit; /** diff --git a/src/Internal/CursorCodec.php b/src/Internal/Cursor/CursorCodec.php similarity index 94% rename from src/Internal/CursorCodec.php rename to src/Internal/Cursor/CursorCodec.php index 6eca4be..994c3c3 100644 --- a/src/Internal/CursorCodec.php +++ b/src/Internal/Cursor/CursorCodec.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery\Internal; +namespace TinyBlocks\HttpQuery\Internal\Cursor; use TinyBlocks\Encoder\Base62; use TinyBlocks\Encoder\Internal\Exceptions\InvalidDecoding; diff --git a/src/Internal/Keyset.php b/src/Internal/Cursor/Keyset.php similarity index 95% rename from src/Internal/Keyset.php rename to src/Internal/Cursor/Keyset.php index ff06495..8a0cfe9 100644 --- a/src/Internal/Keyset.php +++ b/src/Internal/Cursor/Keyset.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery\Internal; +namespace TinyBlocks\HttpQuery\Internal\Cursor; use Closure; use TinyBlocks\Collection\Collection; use TinyBlocks\HttpQuery\Cursor; use TinyBlocks\HttpQuery\CursorPagination; +use TinyBlocks\HttpQuery\Internal\Window; /** * @template TValue diff --git a/src/Internal/Offset.php b/src/Internal/Offset/Offset.php similarity index 87% rename from src/Internal/Offset.php rename to src/Internal/Offset/Offset.php index 6558590..39f13fa 100644 --- a/src/Internal/Offset.php +++ b/src/Internal/Offset/Offset.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery\Internal; +namespace TinyBlocks\HttpQuery\Internal\Offset; use TinyBlocks\HttpQuery\Exceptions\OffsetOutOfRange; +use TinyBlocks\HttpQuery\Internal\Limit; final readonly class Offset { diff --git a/src/Internal/OffsetNavigator.php b/src/Internal/Offset/OffsetNavigator.php similarity index 94% rename from src/Internal/OffsetNavigator.php rename to src/Internal/Offset/OffsetNavigator.php index 63e7b02..baca52c 100644 --- a/src/Internal/OffsetNavigator.php +++ b/src/Internal/Offset/OffsetNavigator.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery\Internal; +namespace TinyBlocks\HttpQuery\Internal\Offset; +use TinyBlocks\HttpQuery\Internal\Limit; use TinyBlocks\HttpQuery\OffsetPagination; final readonly class OffsetNavigator diff --git a/src/Internal/PageCount.php b/src/Internal/Offset/PageCount.php similarity index 86% rename from src/Internal/PageCount.php rename to src/Internal/Offset/PageCount.php index e5f9636..0d1f544 100644 --- a/src/Internal/PageCount.php +++ b/src/Internal/Offset/PageCount.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery\Internal; +namespace TinyBlocks\HttpQuery\Internal\Offset; + +use TinyBlocks\HttpQuery\Internal\Limit; final readonly class PageCount { diff --git a/src/Internal/PageNumber.php b/src/Internal/Offset/PageNumber.php similarity index 91% rename from src/Internal/PageNumber.php rename to src/Internal/Offset/PageNumber.php index abe6a6f..29270ad 100644 --- a/src/Internal/PageNumber.php +++ b/src/Internal/Offset/PageNumber.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery\Internal; +namespace TinyBlocks\HttpQuery\Internal\Offset; use TinyBlocks\HttpQuery\Exceptions\PageNumberOutOfRange; +use TinyBlocks\HttpQuery\Internal\Limit; final readonly class PageNumber { diff --git a/src/Internal/Total.php b/src/Internal/Offset/Total.php similarity index 86% rename from src/Internal/Total.php rename to src/Internal/Offset/Total.php index 8b70bb8..558e6cc 100644 --- a/src/Internal/Total.php +++ b/src/Internal/Offset/Total.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery\Internal; +namespace TinyBlocks\HttpQuery\Internal\Offset; use TinyBlocks\HttpQuery\Exceptions\TotalIsNegative; +use TinyBlocks\HttpQuery\Internal\Limit; final readonly class Total { diff --git a/src/Internal/Uri.php b/src/Internal/Uri.php new file mode 100644 index 0000000..d4dd270 --- /dev/null +++ b/src/Internal/Uri.php @@ -0,0 +1,36 @@ +isEmpty()) { + $template = 'filter=%s'; + $parameters[] = sprintf($template, $filter->toExpression()->value()); + } + + if (!$sort->isEmpty()) { + $template = 'sort=%s'; + $parameters[] = sprintf($template, $sort->toExpression()); + } + + $parameters[] = $pagination->toQueryString(); + $template = '%s?%s'; + + return sprintf($template, $baseUri, implode('&', $parameters)); + } +} diff --git a/src/Links.php b/src/Links.php index 2661f4a..a74490c 100644 --- a/src/Links.php +++ b/src/Links.php @@ -7,13 +7,14 @@ use TinyBlocks\Collection\Collection; use TinyBlocks\Http\Link; use TinyBlocks\Http\LinkRelation; +use TinyBlocks\HttpQuery\Internal\Uri; use TinyBlocks\HttpQuery\Internal\WebLink; /** * Navigation of a result, rendered as a JSON:API body links object or an RFC 8288 Link header. * - *It renders uniformly over a {@see Navigation}, building the self link from the criteria and - * each navigation target by swapping the criteria's pagination, so the filter and the sort are + *
It renders uniformly over a {@see Navigation}, building each URI from the criteria's filter and + * sort plus the pagination of the self link and of every target, so the filter and the sort are * preserved in every URI.
*/ final readonly class Links @@ -35,12 +36,19 @@ private function __construct(private WebLink $self, private Collection $links) */ public static function from(string $baseUri, Criteria $criteria, Navigation $navigation): Links { - $self = new WebLink(uri: $criteria->toUri(baseUri: $baseUri), relation: LinkRelation::SELF); + $uriFor = static fn(Pagination $pagination): string => Uri::from( + sort: $criteria->sorting(), + filter: $criteria->filtering(), + baseUri: $baseUri, + pagination: $pagination + ); + + $self = new WebLink(uri: $uriFor($criteria->pagination()), relation: LinkRelation::SELF); /** @var CollectionWhen the schema is omitted, the default page-size bounds apply. The pagination is a - * {@see CursorPagination} when the cursor is present, otherwise an {@see OffsetPagination}.
- * - * @param ServerRequestInterface $request The incoming PSR-7 server request. - * @param Schema|null $schema The schema carrying the page-size bounds, or null for the default. - * @return Criteria The criteria carrying the parsed filter, sort, and pagination. - * @throws FilterExpressionIsInvalid If the filter expression cannot be parsed. - * @throws SortExpressionIsInvalid If the sort expression cannot be parsed. - * @throws PageNumberOutOfRange If the page number is less than 1. - * @throws PageSizeOutOfRange If the page size falls outside the valid range. - * @throws OffsetOutOfRange If the offset is less than 0. - */ - public static function fromQuery(ServerRequestInterface $request, ?Schema $schema = null): Criteria - { - $schema = $schema ?? Schema::default(); - $query = QueryParameters::from(request: $request); - $page = PageParameters::from(query: $query, schema: $schema); - - $expression = $query->get(key: 'filter')->toString(); - $filter = $expression === '' ? Group::none() : FilterParser::from(input: $expression)->parse(); - $sort = Sort::fromExpression(expression: $query->get(key: 'sort')->toString()); - - return new Criteria(page: $page, sort: $sort, filter: $filter); - } - - /** - * Returns the sorting specification. - * - * @return Sort The sorting specification. - */ - public function sorting(): Sort - { - return $this->sort; - } - - /** - * Returns the filtering specification as the root of the filter tree. - * - * @return Filter The filter tree root, an empty {@see Group} when no filter is present. - */ - public function filtering(): Filter - { - return $this->filter; - } - - /** - * Creates a CursorPage from the items and the ordering-key extractor. - * - * @template TValue - * @param iterableWhen the schema is omitted, an empty contract applies: the default page-size bounds, no + * filterable or sortable field, and no default sort. Any incoming filter or sort is then + * rejected. The pagination always carries the incoming cursor token and the page size.
+ * + * @param ServerRequestInterface $request The incoming PSR-7 server request. + * @param Schema|null $schema The query contract, or null for the empty contract. + * @return Criteria The criteria carrying the validated comparisons, the effective sort, and the pagination. + * @throws FilterExpressionIsInvalid If the filter expression cannot be parsed. + * @throws SortExpressionIsInvalid If the sort expression cannot be parsed. + * @throws PageSizeOutOfRange If the page size falls outside the valid range. + * @throws FilterShapeNotSupported If the filter is not a comparison or an AND group of comparisons. + * @throws FilterFieldNotAllowed If a comparison targets a field that was never allowed. + * @throws FilterOperatorNotAllowed If a comparison uses an operator not allowed for its field. + * @throws FilterValueNotAllowed If a compared value falls outside the permitted set or kind. + * @throws SortFieldNotAllowed If the sort orders by a field that was never declared sortable. + */ + public static function fromQuery(ServerRequestInterface $request, ?Schema $schema = null): Criteria + { + $query = Query::from(schema: $schema ?? Schema::default(), request: $request); + $cursor = Token::from(token: $query->cursorToken()); + + return new Criteria( + sort: $query->sort(), + filter: $query->filter(), + pagination: Pagination::from(cursor: $cursor, perPage: $query->pageSize()), + comparisons: $query->comparisons(), + submittedSort: $query->submittedSort() + ); + } + + /** + * Returns the effective sort. + * + * @return Sort The client sort when present, otherwise the schema default sort. + */ + public function sort(): Sort + { + return $this->sort; + } + + /** + * Creates a Keyset cursor view ordered by the effective sort. + * + *It pairs the effective sort with the validated filter and the cursor view. It works whether + * an incoming cursor token is present, the first page yielding an empty cursor.
+ * + * @return Keyset The cursor view carrying the seek inputs and the page builder. + * @throws SortIsRequired If the effective sort is empty, so the keyset cannot anchor a page. + */ + public function keyset(): Keyset + { + if ($this->sort->isEmpty()) { + throw new SortIsRequired(); + } + + return Keyset::from( + sort: $this->sort, + filter: $this->filter, + pagination: $this->pagination, + submittedSort: $this->submittedSort + ); + } + + /** + * Returns the validated conjunction of comparisons. + * + * @return listWhen the extractor is omitted, the ordering keys are read from the effective sort fields on + * each array-shaped row, so the cursor keys come from the source rows.
+ * + * @template TElement + * @param iterableEvery effective sort field is present. The value is null when there is no incoming cursor, + * that is on the first page.
+ * + * @return arrayThe consumer fetches one element beyond the page size via keyset. The extra element is + * trimmed and its presence is read as the next-page hint. The next cursor anchors on the last + * retained element.
+ * + * @template TElement + * @param Sort $sort The submitted sort preserved in every rendered URI. + * @param iterableEvery field is present. The value is null for every field when the token is absent, that is + * on the first page.
+ * + * @param listThe consumer fetches one element beyond the page size via keyset. The extra element is - * trimmed and its presence is read as the next-page hint. The forward cursor anchors on the - * last retained element, the backward cursor on the first.
- * - * @template TElement - * @param iterableA node is either a {@see Comparison} leaf or a {@see Group} composite. The marker is sealed - * by the two implementations the library ships, so consumers match on the concrete type when they - * translate the tree to their own store.
+ * by the two implementations the library ships, so the parser builds the tree and the criteria + * validates it into the conjunction of comparisons the consumer reads. */ interface Filter { @@ -19,11 +19,4 @@ interface Filter * @return bool True when the filter is empty. */ public function isEmpty(): bool; - - /** - * Returns the filter as its RSQL expression. - * - * @return Expression The RSQL expression. - */ - public function toExpression(): Expression; } diff --git a/src/Group.php b/src/Group.php index cbd11cf..1d989a3 100644 --- a/src/Group.php +++ b/src/Group.php @@ -4,8 +4,6 @@ namespace TinyBlocks\HttpQuery; -use TinyBlocks\Collection\Collection; - /** * Composite of the filter tree joining child filters under a single logical connective. */ @@ -61,19 +59,4 @@ public function operator(): LogicalOperator { return $this->operator; } - - public function toExpression(): Expression - { - $operator = $this->operator; - - /** @var CollectionIt renders uniformly over a {@see Navigation}, building each URI from the criteria's filter and - * sort plus the pagination of the self link and of every target, so the filter and the sort are - * preserved in every URI.
+ *It renders uniformly over a {@see Navigation}, building each URI from the given filter and sort + * plus the pagination of the self link and of every target, so the filter and the sort are + * preserved in every URI. The self link is built from the page's own current pagination, so a cursor + * page renders a cursor self and an offset page renders an offset self.
*/ final readonly class Links { @@ -27,23 +28,30 @@ private function __construct(private WebLink $self, private Collection $links) } /** - * Creates a Links from the base URI, the criteria, and the navigation of a result. + * Creates a Links from the sort, the page pagination, the filter, the base URI, and the navigation. * + * @param Sort $sort The sort preserved in every URI. + * @param Pagination $self The page's own current pagination, rendered as the self link. + * @param Filter $filter The filter preserved in every URI. * @param string $baseUri The base URI the navigation URIs are built on. - * @param Criteria $criteria The criteria that produced the result. * @param Navigation $navigation The navigation the result exposes. * @return Links The navigation for the result. */ - public static function from(string $baseUri, Criteria $criteria, Navigation $navigation): Links - { + public static function from( + Sort $sort, + Pagination $self, + Filter $filter, + string $baseUri, + Navigation $navigation + ): Links { $uriFor = static fn(Pagination $pagination): string => Uri::from( - sort: $criteria->sorting(), - filter: $criteria->filtering(), + sort: $sort, + filter: $filter, baseUri: $baseUri, pagination: $pagination ); - $self = new WebLink(uri: $uriFor($criteria->pagination()), relation: LinkRelation::SELF); + $current = new WebLink(uri: $uriFor($self), relation: LinkRelation::SELF); /** @var CollectionWhen the schema is omitted, an empty contract applies: the default page-size bounds, no + * filterable or sortable field, and no default sort. Any incoming filter or sort is then + * rejected. The pagination derives its offset from the one-based page number and the page size.
+ * + * @param ServerRequestInterface $request The incoming PSR-7 server request. + * @param Schema|null $schema The query contract, or null for the empty contract. + * @return Criteria The criteria carrying the validated comparisons, the effective sort, and the pagination. + * @throws FilterExpressionIsInvalid If the filter expression cannot be parsed. + * @throws SortExpressionIsInvalid If the sort expression cannot be parsed. + * @throws PageNumberOutOfRange If the page number is less than 1. + * @throws PageSizeOutOfRange If the page size falls outside the valid range. + * @throws FilterShapeNotSupported If the filter is not a comparison or an AND group of comparisons. + * @throws FilterFieldNotAllowed If a comparison targets a field that was never allowed. + * @throws FilterOperatorNotAllowed If a comparison uses an operator not allowed for its field. + * @throws FilterValueNotAllowed If a compared value falls outside the permitted set or kind. + * @throws SortFieldNotAllowed If the sort orders by a field that was never declared sortable. + */ + public static function fromQuery(ServerRequestInterface $request, ?Schema $schema = null): Criteria + { + $query = Query::from(schema: $schema ?? Schema::default(), request: $request); + + return new Criteria( + sort: $query->sort(), + filter: $query->filter(), + pagination: Pagination::fromPage(page: $query->pageNumber(), perPage: $query->pageSize()), + comparisons: $query->comparisons(), + submittedSort: $query->submittedSort() + ); + } + + /** + * Creates a Page from the total element count and the items. + * + * @template TValue + * @param int $total The total element count across every page. + * @param iterableThe page-based factory derives the offset from the one-based page number and the page size.
*/ -final readonly class OffsetPagination implements Pagination +final readonly class Pagination implements PaginationContract { private function __construct(private Limit $limit, private Offset $offset) { } /** - * Creates an OffsetPagination from a one-based page number and a page size. + * Creates a Pagination from a one-based page number and a page size. * * @param int $page The one-based page number. * @param int $perPage The page size. - * @return OffsetPagination The pagination whose offset is derived from the page and the page size. + * @return Pagination The pagination whose offset is derived from the page and the page size. * @throws PageNumberOutOfRange If the page number is less than 1. * @throws PageSizeOutOfRange If the page size is less than 1. */ - public static function fromPage(int $page, int $perPage): OffsetPagination + public static function fromPage(int $page, int $perPage): Pagination { $limit = Limit::from(value: $perPage); $number = PageNumber::from(value: $page); - return new OffsetPagination(limit: $limit, offset: Offset::fromPage(page: $number, limit: $limit)); + return new Pagination(limit: $limit, offset: Offset::fromPage(page: $number, limit: $limit)); } /** - * Creates an OffsetPagination from a zero-based offset and a limit. + * Creates a Pagination from a limit and a zero-based offset. * - * @param int $offset The zero-based offset. * @param int $limit The maximum number of elements per page. - * @return OffsetPagination The pagination carrying the offset and the limit. + * @param int $offset The zero-based offset. + * @return Pagination The pagination carrying the offset and the limit. * @throws OffsetOutOfRange If the offset is less than 0. * @throws PageSizeOutOfRange If the limit is less than 1. */ - public static function fromOffset(int $offset, int $limit): OffsetPagination + public static function fromOffset(int $limit, int $offset): Pagination { - return new OffsetPagination(limit: Limit::from(value: $limit), offset: Offset::from(value: $offset)); + return new Pagination(limit: Limit::from(value: $limit), offset: Offset::from(value: $offset)); } /** @@ -60,7 +61,7 @@ public static function fromOffset(int $offset, int $limit): OffsetPagination */ public function page(): int { - return PageNumber::fromOffset(offset: $this->offset, limit: $this->limit)->value(); + return PageNumber::fromOffset(limit: $this->limit, offset: $this->offset)->value(); } public function limit(): int diff --git a/src/OffsetSlice.php b/src/Offset/Slice.php similarity index 56% rename from src/OffsetSlice.php rename to src/Offset/Slice.php index 1e76eb5..3df8097 100644 --- a/src/OffsetSlice.php +++ b/src/Offset/Slice.php @@ -2,68 +2,64 @@ declare(strict_types=1); -namespace TinyBlocks\HttpQuery; +namespace TinyBlocks\HttpQuery\Offset; use Psr\Http\Message\ResponseInterface; use TinyBlocks\Collection\Collection; use TinyBlocks\Http\LinkRelation; -use TinyBlocks\Http\Server\Response; -use TinyBlocks\HttpQuery\Internal\Limit; -use TinyBlocks\HttpQuery\Internal\Offset\Offset; -use TinyBlocks\HttpQuery\Internal\Offset\OffsetNavigator; -use TinyBlocks\HttpQuery\Internal\Offset\PageNumber; +use TinyBlocks\HttpQuery\Filter; +use TinyBlocks\HttpQuery\Internal\Offset\OffsetNavigation; +use TinyBlocks\HttpQuery\Internal\Rendering; use TinyBlocks\HttpQuery\Internal\Window; +use TinyBlocks\HttpQuery\Navigation; +use TinyBlocks\HttpQuery\Sort; /** * Offset-based slice carrying its items and the next-page hint, without a total element count. * * @template TValue */ -final readonly class OffsetSlice +final readonly class Slice { /** * @param CollectionThe consumer fetches one element beyond the page size. The extra element is trimmed and * its presence is read as the next-page hint.
* * @template TElement + * @param Sort $sort The submitted sort preserved in every rendered URI. * @param iterableIt is implemented by the offset-based {@see OffsetPagination} and the keyset {@see CursorPagination}, - * so a {@see Criteria} carries either one without branching on the concrete type.
+ *It is implemented by the offset-based {@see Offset\Pagination} and the + * keyset {@see Cursor\Pagination}, so {@see Links} renders a page over either + * approach without branching on the concrete type.
*/ interface Pagination { diff --git a/src/Schema.php b/src/Schema.php index a7dd2d1..c5b73aa 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -4,18 +4,32 @@ namespace TinyBlocks\HttpQuery; +use TinyBlocks\HttpQuery\Exceptions\FilterFieldNotAllowed; +use TinyBlocks\HttpQuery\Exceptions\FilterOperatorNotAllowed; +use TinyBlocks\HttpQuery\Exceptions\FilterShapeNotSupported; +use TinyBlocks\HttpQuery\Exceptions\FilterValueNotAllowed; use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange; +use TinyBlocks\HttpQuery\Exceptions\SortFieldNotAllowed; +use TinyBlocks\HttpQuery\Internal\AllowedFilters; +use TinyBlocks\HttpQuery\Internal\Conjunction; /** - * Page-size bounds used to read and write a Criteria. + * Declarative contract of the query an endpoint accepts, used to validate an incoming request. * - *The query parameter names follow JSON:API and are fixed: filter, sort,
+ *
It declares the filterable fields with their permitted operators, values, and kinds, the
+ * client-sortable fields, the sort applied when the client sends none, and the page-size bounds.
+ * The query parameter names follow JSON:API and are fixed: filter, sort,
* and the page family. The default page size is 20 and the maximum is 100.
STRING is any non-empty string, an INTEGER is an optionally
+ * signed sequence of digits, and a DATETIME is an ISO-8601 date or date-time.
+ */
+enum ValueKind: string
+{
+ case STRING = 'string';
+ case INTEGER = 'integer';
+ case DATETIME = 'datetime';
+
+ /**
+ * Tells whether the value matches this kind.
+ *
+ * @param string $value The value to test against the kind.
+ * @return bool True when the value matches the kind.
+ */
+ public function matches(string $value): bool
+ {
+ return match ($this) {
+ ValueKind::STRING => $value !== '',
+ ValueKind::INTEGER => preg_match('/^-?\d+$/', $value) === 1,
+ ValueKind::DATETIME => Iso8601::isValid(value: $value)
+ };
+ }
+}
diff --git a/tests/Unit/ComparisonTest.php b/tests/Unit/ComparisonTest.php
new file mode 100644
index 0000000..a438c0e
--- /dev/null
+++ b/tests/Unit/ComparisonTest.php
@@ -0,0 +1,120 @@
+field();
+
+ /** @Then it returns the field being compared */
+ self::assertSame('status', $field);
+ }
+
+ public function testValuesThenReturnsTheComparedValues(): void
+ {
+ /** @Given an IN comparison carrying several values */
+ $comparison = Comparison::of(field: 'role', values: ['admin', 'user'], operator: Operator::IN);
+
+ /** @When reading the compared values */
+ $values = $comparison->values();
+
+ /** @Then it returns the values compared against the field in order */
+ self::assertSame(['admin', 'user'], $values);
+ }
+
+ public function testIsEmptyThenReportsItIsNotEmpty(): void
+ {
+ /** @Given an equality comparison */
+ $comparison = Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL);
+
+ /** @When checking whether it is empty */
+ $isEmpty = $comparison->isEmpty();
+
+ /** @Then it reports itself as not empty */
+ self::assertFalse($isEmpty);
+ }
+
+ public function testOperatorThenReturnsTheComparisonOperator(): void
+ {
+ /** @Given an equality comparison */
+ $comparison = Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL);
+
+ /** @When reading the comparison operator */
+ $operator = $comparison->operator();
+
+ /** @Then it returns the operator the comparison carries */
+ self::assertSame(Operator::EQUAL, $operator);
+ }
+
+ public function testHasFieldWhenFieldMatchesThenIsTrue(): void
+ {
+ /** @Given an equality comparison on a field */
+ $comparison = Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL);
+
+ /** @When asking whether it targets that field */
+ $hasField = $comparison->hasField(field: 'status');
+
+ /** @Then it reports that it targets the field */
+ self::assertTrue($hasField);
+ }
+
+ public function testHasFieldWhenFieldDiffersThenIsFalse(): void
+ {
+ /** @Given an equality comparison on a field */
+ $comparison = Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL);
+
+ /** @When asking whether it targets another field */
+ $hasField = $comparison->hasField(field: 'total');
+
+ /** @Then it reports that it does not target the field */
+ self::assertFalse($hasField);
+ }
+
+ public function testFirstValueWhenMultipleValuesThenReturnsTheFirst(): void
+ {
+ /** @Given an IN comparison carrying several values */
+ $comparison = Comparison::of(field: 'role', values: ['admin', 'user'], operator: Operator::IN);
+
+ /** @When reading the first compared value */
+ $firstValue = $comparison->firstValue();
+
+ /** @Then it returns the first of the compared values */
+ self::assertSame('admin', $firstValue);
+ }
+
+ public function testHasOperatorWhenOperatorMatchesThenIsTrue(): void
+ {
+ /** @Given an equality comparison */
+ $comparison = Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL);
+
+ /** @When asking whether it carries the equality operator */
+ $hasOperator = $comparison->hasOperator(operator: Operator::EQUAL);
+
+ /** @Then it reports that it carries the operator */
+ self::assertTrue($hasOperator);
+ }
+
+ public function testHasOperatorWhenOperatorDiffersThenIsFalse(): void
+ {
+ /** @Given an equality comparison */
+ $comparison = Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL);
+
+ /** @When asking whether it carries another operator */
+ $hasOperator = $comparison->hasOperator(operator: Operator::NOT_EQUAL);
+
+ /** @Then it reports that it does not carry the operator */
+ self::assertFalse($hasOperator);
+ }
+}
diff --git a/tests/Unit/CriteriaTest.php b/tests/Unit/CriteriaTest.php
deleted file mode 100644
index bc1a97c..0000000
--- a/tests/Unit/CriteriaTest.php
+++ /dev/null
@@ -1,228 +0,0 @@
-sorting()->isEmpty());
- }
-
- public function testFromQueryWhenEmptyThenFilteringIsAnEmptyGroup(): void
- {
- /** @Given empty query parameters */
- $query = Query::from(parameters: []);
-
- /** @When building the criteria from the query */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @Then the filtering is an empty group */
- self::assertInstanceOf(Group::class, $criteria->filtering());
-
- /** @And the group carries no child filter */
- self::assertSame([], $criteria->filtering()->filters());
- }
-
- public function testFromQueryWhenNoCursorThenPaginationIsOffsetBased(): void
- {
- /** @Given query parameters without a cursor */
- $query = Query::from(parameters: ['page' => ['number' => '2', 'size' => '15']]);
-
- /** @When building the criteria from the query */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @Then the pagination is offset-based */
- self::assertInstanceOf(OffsetPagination::class, $criteria->pagination());
- }
-
- public function testFromQueryWhenPerPageIsAtTheMaximumThenItIsAccepted(): void
- {
- /** @Given query parameters carrying a page size at the default maximum */
- $query = Query::from(parameters: ['page' => ['size' => '100']]);
-
- /** @When building the criteria from the query */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @Then the pagination is offset-based */
- self::assertInstanceOf(OffsetPagination::class, $criteria->pagination());
-
- /** @And it carries the maximum page size */
- self::assertSame(100, $criteria->pagination()->limit());
- }
-
- public function testCursorPageWhenBuiltFromRequestThenReturnsCursorPage(): void
- {
- /** @Given a criteria parsed from a request carrying a page size of two */
- $criteria = Criteria::fromQuery(request: Query::from(parameters: ['page' => ['size' => '2']]));
-
- /** @When building a cursor page from the items fetched for the page size plus one */
- $page = $criteria->cursorPage(items: [10, 20, 30], keysOf: static fn(mixed $element): array => [$element]);
-
- /** @Then the result is a cursor page */
- self::assertInstanceOf(CursorPage::class, $page);
-
- /** @And the items are trimmed to the page size */
- self::assertSame([10, 20], $page->items()->toArray());
- }
-
- public function testOffsetPageWhenBuiltFromRequestThenReturnsOffsetPage(): void
- {
- /** @Given a criteria parsed from a request carrying a page size of three */
- $criteria = Criteria::fromQuery(request: Query::from(parameters: ['page' => ['size' => '3']]));
-
- /** @When building an offset page from the items and the total element count */
- $page = $criteria->offsetPage(items: ['a', 'b', 'c'], total: 30);
-
- /** @Then the result is an offset page */
- self::assertInstanceOf(OffsetPage::class, $page);
-
- /** @And it carries the total element count */
- self::assertSame(30, $page->total());
- }
-
- public function testOffsetSliceWhenBuiltFromRequestThenReturnsOffsetSlice(): void
- {
- /** @Given a criteria parsed from a request carrying a page size of three */
- $criteria = Criteria::fromQuery(request: Query::from(parameters: ['page' => ['size' => '3']]));
-
- /** @When building an offset slice from the items fetched for the page size plus one */
- $slice = $criteria->offsetSlice(items: ['a', 'b', 'c', 'd']);
-
- /** @Then the result is an offset slice */
- self::assertInstanceOf(OffsetSlice::class, $slice);
-
- /** @And it reports a next page from the trimmed extra element */
- self::assertTrue($slice->hasNext());
- }
-
- public function testFromQueryWhenCursorIsPresentThenPaginationIsCursorBased(): void
- {
- /** @Given query parameters carrying a cursor */
- $query = Query::from(parameters: ['page' => ['cursor' => 'abc', 'size' => '10']]);
-
- /** @When building the criteria from the query */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @Then the pagination is cursor-based */
- self::assertInstanceOf(CursorPagination::class, $criteria->pagination());
-
- /** @And it carries the requested page size */
- self::assertSame(10, $criteria->pagination()->limit());
-
- /** @And it carries the incoming cursor token */
- self::assertSame('abc', $criteria->pagination()->cursor()->toString());
- }
-
- public function testFromQueryWhenEmptyThenPaginationCarriesDefaultPageAndLimit(): void
- {
- /** @Given empty query parameters */
- $query = Query::from(parameters: []);
-
- /** @When building the criteria from the query */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @Then the pagination is offset-based */
- self::assertInstanceOf(OffsetPagination::class, $criteria->pagination());
-
- /** @And it starts at the first page */
- self::assertSame(1, $criteria->pagination()->page());
-
- /** @And it carries the default page size */
- self::assertSame(20, $criteria->pagination()->limit());
- }
-
- public function testFromQueryWhenCustomSchemaGivenThenAppliesItsDefaultPageSize(): void
- {
- /** @Given query parameters carrying a page number without a page size */
- $query = Query::from(parameters: ['page' => ['number' => '2']]);
-
- /** @And a schema lowering the default page size */
- $schema = Schema::default()->withDefaultPerPage(defaultPerPage: 5);
-
- /** @When building the criteria from the query and the schema */
- $criteria = Criteria::fromQuery(request: $query, schema: $schema);
-
- /** @Then the pagination is offset-based */
- self::assertInstanceOf(OffsetPagination::class, $criteria->pagination());
-
- /** @And the pagination points at the requested page */
- self::assertSame(2, $criteria->pagination()->page());
-
- /** @And the pagination carries the schema default page size */
- self::assertSame(5, $criteria->pagination()->limit());
- }
-
- public function testFromQueryWhenPerPageAboveMaximumThenThrowsPageSizeOutOfRange(): void
- {
- /** @Given query parameters carrying a page size above the default maximum */
- $query = Query::from(parameters: ['page' => ['size' => '500']]);
-
- /** @Then an exception indicating the page size is out of range is raised */
- $this->expectException(PageSizeOutOfRange::class);
- $this->expectExceptionMessage('Page size');
-
- /** @When building the criteria from the query */
- Criteria::fromQuery(request: $query);
- }
-
- public function testFromQueryWhenFilterSortAndPageGivenThenEachSpecificationIsParsed(): void
- {
- /** @Given query parameters carrying a filter, a sort, a page, and a page size */
- $query = Query::from(parameters: [
- 'sort' => '-created_at',
- 'page' => ['number' => '2', 'size' => '15'],
- 'filter' => 'status==paid'
- ]);
-
- /** @When building the criteria from the query */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @Then the filtering is a comparison */
- self::assertInstanceOf(Comparison::class, $criteria->filtering());
-
- /** @And the comparison targets the filtered field */
- self::assertSame('status', $criteria->filtering()->field());
-
- /** @And the sorting carries exactly one order */
- self::assertCount(1, $criteria->sorting()->orders());
-
- /** @And the single order sorts by the descending field */
- self::assertSame('created_at', $criteria->sorting()->orders()[0]->field());
-
- /** @And the single order applies descending direction */
- self::assertSame(Direction::DESCENDING, $criteria->sorting()->orders()[0]->direction());
-
- /** @And the pagination is offset-based */
- self::assertInstanceOf(OffsetPagination::class, $criteria->pagination());
-
- /** @And it points at the requested page */
- self::assertSame(2, $criteria->pagination()->page());
-
- /** @And it carries the requested page size */
- self::assertSame(15, $criteria->pagination()->limit());
- }
-}
diff --git a/tests/Unit/Cursor/CriteriaTest.php b/tests/Unit/Cursor/CriteriaTest.php
new file mode 100644
index 0000000..7eba9fe
--- /dev/null
+++ b/tests/Unit/Cursor/CriteriaTest.php
@@ -0,0 +1,173 @@
+sort()->isEmpty());
+ }
+
+ public function testFromQueryWhenEmptyThenComparisonsAreEmpty(): void
+ {
+ /** @Given empty query parameters */
+ $query = Query::from(parameters: []);
+
+ /** @When building the criteria from the query */
+ $criteria = Criteria::fromQuery(request: $query);
+
+ /** @Then there is no comparison */
+ self::assertSame([], $criteria->comparisons());
+ }
+
+ public function testKeysetWhenEffectiveSortIsEmptyThenThrowsSortIsRequired(): void
+ {
+ /** @Given a criteria parsed from a request carrying no sort and no schema default */
+ $criteria = Criteria::fromQuery(request: Query::from(parameters: []));
+
+ /** @Then an exception indicating a deterministic order is required is raised */
+ $this->expectException(SortIsRequired::class);
+ $this->expectExceptionMessage('A keyset requires a deterministic order, but the effective sort is empty.');
+
+ /** @When building the keyset view */
+ $criteria->keyset();
+ }
+
+ public function testKeysetWhenBuiltWithoutCursorThenBuildsCursorPage(): void
+ {
+ /** @Given a schema declaring a default sort over the identifier */
+ $schema = Schema::create()->defaultSort(sort: Sort::fromExpression(expression: 'id'));
+
+ /** @And a criteria parsed from a request carrying a page size of two and no cursor */
+ $criteria = Criteria::fromQuery(request: Query::from(parameters: ['page' => ['size' => '2']]), schema: $schema);
+
+ /** @When building a cursor page through the keyset view over the array rows fetched */
+ $page = $criteria->keyset()->page(items: [['id' => 10], ['id' => 20], ['id' => 30]]);
+
+ /** @Then the result is a cursor page */
+ self::assertInstanceOf(Page::class, $page);
+
+ /** @And the items are trimmed to the page size */
+ self::assertSame([['id' => 10], ['id' => 20]], $page->items()->toArray());
+ }
+
+ public function testKeysetWhenIncomingCursorPresentThenBuildsCursorPage(): void
+ {
+ /** @Given an opaque token produced from ordering key values */
+ $token = Token::fromKeys(keys: [5])->toString();
+
+ /** @And a schema declaring a default sort over the identifier */
+ $schema = Schema::create()->defaultSort(sort: Sort::fromExpression(expression: 'id'));
+
+ /** @And a criteria parsed from a request carrying that cursor and a page size of two */
+ $criteria = Criteria::fromQuery(
+ request: Query::from(parameters: ['page' => ['cursor' => $token, 'size' => '2']]),
+ schema: $schema
+ );
+
+ /** @When building a cursor page through the keyset view over the items fetched */
+ $page = $criteria->keyset()->page(items: [10, 20, 30], keysOf: static fn(int $element): array => [$element]);
+
+ /** @Then the cursor page reports a next page */
+ self::assertTrue($page->hasNext());
+
+ /** @And the items are trimmed to the page size */
+ self::assertSame([10, 20], $page->items()->toArray());
+ }
+
+ public function testFromQueryWhenCursorPresentThenKeysetCarriesPageSizeAndCursor(): void
+ {
+ /** @Given an opaque token produced from a single ordering key value */
+ $token = Token::fromKeys(keys: [5])->toString();
+
+ /** @And a schema declaring a default sort over the identifier */
+ $schema = Schema::create()->defaultSort(sort: Sort::fromExpression(expression: 'id'));
+
+ /** @And a criteria parsed from a request carrying that cursor and a page size of ten */
+ $criteria = Criteria::fromQuery(
+ request: Query::from(parameters: ['page' => ['cursor' => $token, 'size' => '10']]),
+ schema: $schema
+ );
+
+ /** @When building the keyset view */
+ $keyset = $criteria->keyset();
+
+ /** @Then the keyset carries the requested page size */
+ self::assertSame(10, $keyset->limit());
+
+ /** @And the keyset decodes the incoming cursor keyed by the sort field */
+ self::assertSame(['id' => 5], $keyset->cursor());
+ }
+
+ public function testFromQueryWhenCustomSchemaGivenThenAppliesItsDefaultPageSize(): void
+ {
+ /** @Given a schema lowering the default page size and declaring a default sort */
+ $schema = Schema::create()
+ ->defaultPerPage(defaultPerPage: 5)
+ ->defaultSort(sort: Sort::fromExpression(expression: 'id'));
+
+ /** @When building the keyset view from a query carrying no page size and the schema */
+ $keyset = Criteria::fromQuery(request: Query::from(parameters: []), schema: $schema)->keyset();
+
+ /** @Then the keyset carries the schema default page size */
+ self::assertSame(5, $keyset->limit());
+ }
+
+ public function testFromQueryWhenFilterAndSortGivenThenEachSpecificationIsValidated(): void
+ {
+ /** @Given a schema allowing the filtered and sorted fields */
+ $schema = Schema::create()
+ ->sortable(fields: ['created_at'])
+ ->filterable(field: 'status', operators: [Operator::EQUAL]);
+
+ /** @And a query carrying a filter and a sort */
+ $query = Query::from(parameters: ['sort' => '-created_at', 'filter' => 'status==paid']);
+
+ /** @When building the criteria from the query and the schema */
+ $criteria = Criteria::fromQuery(request: $query, schema: $schema);
+
+ /** @Then the validated comparisons carry the filtered field and value */
+ self::assertEquals(
+ [Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL)],
+ $criteria->comparisons()
+ );
+
+ /** @And the effective sort is the client sort */
+ self::assertEquals(Sort::fromExpression(expression: '-created_at'), $criteria->sort());
+ }
+
+ public function testFromQueryWhenPerPageAboveMaximumThenThrowsPageSizeOutOfRange(): void
+ {
+ /** @Given query parameters carrying a page size above the default maximum */
+ $query = Query::from(parameters: ['page' => ['size' => '500']]);
+
+ /** @Then an exception indicating the page size is out of range is raised */
+ $this->expectException(PageSizeOutOfRange::class);
+ $this->expectExceptionMessage('Page size');
+
+ /** @When building the criteria from the query */
+ Criteria::fromQuery(request: $query);
+ }
+}
diff --git a/tests/Unit/Cursor/KeysetTest.php b/tests/Unit/Cursor/KeysetTest.php
new file mode 100644
index 0000000..cabd19c
--- /dev/null
+++ b/tests/Unit/Cursor/KeysetTest.php
@@ -0,0 +1,144 @@
+schema = Schema::create()->sortable(fields: ['id', 'name', 'created_at']);
+ }
+
+ public function testLimitThenReturnsThePageSize(): void
+ {
+ /** @Given a keyset view built from a request carrying a sort and a page size of fifteen */
+ $keyset = Criteria::fromQuery(
+ request: Query::from(parameters: ['sort' => 'id', 'page' => ['size' => '15']]),
+ schema: $this->schema
+ )->keyset();
+
+ /** @When reading the page size */
+ $limit = $keyset->limit();
+
+ /** @Then it returns the requested page size */
+ self::assertSame(15, $limit);
+ }
+
+ public function testOrdersWhenSortGivenThenReturnsItsOrders(): void
+ {
+ /** @Given a keyset view built from a descending sort over a single field */
+ $keyset = Criteria::fromQuery(request: Query::from(parameters: ['sort' => '-name']), schema: $this->schema)
+ ->keyset();
+
+ /** @When reading the orders */
+ $orders = $keyset->orders();
+
+ /** @Then it returns the orders of the effective sort */
+ self::assertEquals([Order::from(field: 'name', direction: Direction::DESCENDING)], $orders);
+ }
+
+ public function testPageWhenKeysOfGivenThenUsesTheExtractor(): void
+ {
+ /** @Given a keyset view built from a request carrying a sort and a page size of two */
+ $keyset = Criteria::fromQuery(
+ request: Query::from(parameters: ['sort' => 'id', 'page' => ['size' => '2']]),
+ schema: $this->schema
+ )->keyset();
+
+ /** @When building the page from the items and an explicit key extractor */
+ $page = $keyset->page(items: [10, 20, 30], keysOf: static fn(int $element): array => [$element]);
+
+ /** @Then the items are trimmed to the page size */
+ self::assertSame([10, 20], $page->items()->toArray());
+
+ /** @And the next pagination anchors on the keys extracted from the last retained element */
+ self::assertEquals(Pagination::from(perPage: 2, cursor: Token::fromKeys(keys: [20])), $page->next());
+ }
+
+ public function testPageWhenNoKeysOfGivenThenDerivesKeysFromTheSortFields(): void
+ {
+ /** @Given a keyset view built from a request carrying a sort and a page size of two */
+ $keyset = Criteria::fromQuery(
+ request: Query::from(parameters: ['sort' => 'id', 'page' => ['size' => '2']]),
+ schema: $this->schema
+ )->keyset();
+
+ /** @When building the page from array rows without a key extractor */
+ $page = $keyset->page(items: [['id' => 10], ['id' => 20], ['id' => 30]]);
+
+ /** @Then the items are trimmed to the page size */
+ self::assertSame([['id' => 10], ['id' => 20]], $page->items()->toArray());
+
+ /** @And the next pagination anchors on the sort-field keys of the last retained row */
+ self::assertEquals(Pagination::from(perPage: 2, cursor: Token::fromKeys(keys: [20])), $page->next());
+ }
+
+ public function testCursorWhenNoIncomingCursorThenEverySortFieldIsNull(): void
+ {
+ /** @Given a keyset view built from a request carrying a sort over two fields */
+ $keyset = Criteria::fromQuery(
+ request: Query::from(parameters: ['sort' => 'created_at,id', 'page' => ['size' => '2']]),
+ schema: $this->schema
+ )->keyset();
+
+ /** @When reading the incoming cursor key values */
+ $cursor = $keyset->cursor();
+
+ /** @Then every sort field is present with a null value */
+ self::assertSame(['created_at' => null, 'id' => null], $cursor);
+ }
+
+ public function testCursorWhenIncomingCursorGivenThenKeysValuesBySortField(): void
+ {
+ /** @Given an opaque token produced from the ordering key values */
+ $token = Token::fromKeys(keys: ['2023-01-15T10:30:00Z', 5])->toString();
+
+ /** @And a keyset view built from a request carrying that cursor and a sort over two fields */
+ $keyset = Criteria::fromQuery(
+ request: Query::from(
+ parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => $token, 'size' => '2']]
+ ),
+ schema: $this->schema
+ )->keyset();
+
+ /** @When reading the incoming cursor key values */
+ $cursor = $keyset->cursor();
+
+ /** @Then the values are keyed by the sort field names */
+ self::assertSame(['created_at' => '2023-01-15T10:30:00Z', 'id' => 5], $cursor);
+ }
+
+ public function testCursorWhenDecodedCountMismatchesThenThrowsCursorIsInvalid(): void
+ {
+ /** @Given an opaque token carrying a single key value */
+ $token = Token::fromKeys(keys: [5])->toString();
+
+ /** @And a keyset view whose sort carries two fields */
+ $keyset = Criteria::fromQuery(
+ request: Query::from(
+ parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => $token, 'size' => '2']]
+ ),
+ schema: $this->schema
+ )->keyset();
+
+ /** @Then an exception indicating the cursor is invalid is raised */
+ $this->expectException(CursorIsInvalid::class);
+
+ /** @When reading the incoming cursor key values */
+ $keyset->cursor();
+ }
+}
diff --git a/tests/Unit/Cursor/PageTest.php b/tests/Unit/Cursor/PageTest.php
new file mode 100644
index 0000000..eb894cd
--- /dev/null
+++ b/tests/Unit/Cursor/PageTest.php
@@ -0,0 +1,232 @@
+sort = Sort::fromExpression(expression: '');
+ $this->filter = Group::none();
+ }
+
+ public function testNavigationWhenNoExtraElementThenHasNoNextPage(): void
+ {
+ /** @Given a keyset pagination with an absent incoming cursor and a page size of two */
+ $pagination = Pagination::from(perPage: 2, cursor: Token::none());
+
+ /** @And a cursor page built from items fetched within the page size */
+ $page = Page::from(
+ sort: $this->sort,
+ filter: $this->filter,
+ items: [10, 20],
+ keysOf: static fn(int $element): array => [$element],
+ pagination: $pagination
+ );
+
+ /** @Then the page reports no next page */
+ self::assertFalse($page->hasNext());
+
+ /** @And there is no next pagination */
+ self::assertNull($page->next());
+
+ /** @And the navigation lists no target */
+ self::assertCount(0, $page->navigation()->targets()->toArray());
+
+ /** @And the metadata reports no next page in length-ascending key order */
+ self::assertSame(['has_next' => false, 'per_page' => 2], $page->metadata());
+ }
+
+ public function testToResponseWhenCursorPageGivenThenRendersBodyAndLinkHeader(): void
+ {
+ /** @Given an opaque token produced from ordering key values */
+ $token = Token::fromKeys(keys: [5])->toString();
+
+ /** @And a cursor page built over items fetched for the page size plus one */
+ $page = Page::from(
+ sort: $this->sort,
+ filter: $this->filter,
+ items: [10, 20, 30],
+ keysOf: static fn(int $element): array => [$element],
+ pagination: Pagination::from(perPage: 2, cursor: Token::from(token: $token))
+ );
+
+ /** @When rendering the cursor page as a JSON:API response over the orders base URI */
+ $response = $page->toResponse(baseUri: '/v1/orders');
+
+ /** @Then the response body carries the trimmed data, the meta, and the forward-only links */
+ self::assertSame([
+ 'data' => [10, 20],
+ 'meta' => [
+ 'has_next' => true,
+ 'per_page' => 2
+ ],
+ 'links' => [
+ 'self' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', $token),
+ 'next' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', Token::fromKeys(keys: [20])->toString())
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+
+ /** @And the Link header folds the self and next relations */
+ self::assertSame(implode(', ', [
+ sprintf('; rel="self"', $token),
+ sprintf('; rel="next"', Token::fromKeys(keys: [20])->toString())
+ ]), $response->getHeaderLine('Link'));
+ }
+
+ public function testToResponseWhenFirstCursorPageThenSelfLinkIsCursorStyle(): void
+ {
+ /** @Given a cursor page on the first page with no incoming cursor */
+ $page = Page::from(
+ sort: $this->sort,
+ filter: $this->filter,
+ items: [10, 20, 30],
+ keysOf: static fn(int $element): array => [$element],
+ pagination: Pagination::from(perPage: 2, cursor: Token::none())
+ );
+
+ /** @When rendering the first cursor page as a JSON:API response over the orders base URI */
+ $response = $page->toResponse(baseUri: '/v1/orders');
+
+ /** @Then the self link is cursor-style, carrying only the page size and never an offset page number */
+ self::assertSame([
+ 'data' => [10, 20],
+ 'meta' => [
+ 'has_next' => true,
+ 'per_page' => 2
+ ],
+ 'links' => [
+ 'self' => '/v1/orders?page[size]=2',
+ 'next' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', Token::fromKeys(keys: [20])->toString())
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testNavigationWhenExtraElementFetchedThenListsOnlyTheNextTarget(): void
+ {
+ /** @Given a keyset page fetched for the page size plus one with an absent incoming cursor */
+ $page = Page::from(
+ sort: $this->sort,
+ filter: $this->filter,
+ items: [10, 20, 30],
+ keysOf: static fn(int $element): array => [$element],
+ pagination: Pagination::from(perPage: 2, cursor: Token::none())
+ );
+
+ /** @When reading the navigation targets */
+ $targets = $page->navigation()->targets();
+
+ /** @Then it lists a single navigation target */
+ self::assertCount(1, $targets->toArray());
+
+ /** @And the only target is the next target anchored on the forward cursor */
+ self::assertEquals(
+ NavigationTarget::to(
+ target: Pagination::from(perPage: 2, cursor: Token::fromKeys(keys: [20])),
+ relation: LinkRelation::NEXT
+ ),
+ $targets->first()
+ );
+ }
+
+ public function testMapWhenTransformationGivenThenProjectsItemsAndPreservesTheCursor(): void
+ {
+ /** @Given a cursor page built over items fetched for the page size plus one */
+ $page = Page::from(
+ sort: $this->sort,
+ filter: $this->filter,
+ items: [10, 20, 30],
+ keysOf: static fn(int $element): array => [$element],
+ pagination: Pagination::from(perPage: 2, cursor: Token::none())
+ );
+
+ /** @When mapping the items through a projection */
+ $mapped = $page->map(transformation: static fn(int $element): string => sprintf('#%d', $element));
+
+ /** @Then the items are projected through the transformation */
+ self::assertSame(['#10', '#20'], $mapped->items()->toArray());
+
+ /** @And the next page hint is preserved */
+ self::assertTrue($mapped->hasNext());
+
+ /** @And the next pagination still anchors on the source keys */
+ self::assertEquals(Pagination::from(perPage: 2, cursor: Token::fromKeys(keys: [20])), $mapped->next());
+
+ /** @And the metadata is preserved in length-ascending key order */
+ self::assertSame(['has_next' => true, 'per_page' => 2], $mapped->metadata());
+ }
+
+ public function testToResponseWhenPageIsMappedThenRendersProjectedDataAndPreservedLinks(): void
+ {
+ /** @Given an opaque token produced from ordering key values */
+ $token = Token::fromKeys(keys: [5])->toString();
+
+ /** @And a cursor page built over array rows then projected to their identifiers */
+ $page = Page::from(
+ sort: $this->sort,
+ filter: $this->filter,
+ items: [['id' => 10], ['id' => 20], ['id' => 30]],
+ keysOf: static fn(array $row): array => [$row['id']],
+ pagination: Pagination::from(perPage: 2, cursor: Token::from(token: $token))
+ )->map(transformation: static fn(array $row): int => (int)$row['id']);
+
+ /** @When rendering the mapped cursor page as a JSON:API response over the orders base URI */
+ $response = $page->toResponse(baseUri: '/v1/orders');
+
+ /** @Then the body carries the projected data, the meta, and the forward-only links */
+ self::assertSame([
+ 'data' => [10, 20],
+ 'meta' => [
+ 'has_next' => true,
+ 'per_page' => 2
+ ],
+ 'links' => [
+ 'self' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', $token),
+ 'next' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', Token::fromKeys(keys: [20])->toString())
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testNavigationWhenExtraElementFetchedThenHasNextAndNextCursorAnchorsLastItem(): void
+ {
+ /** @Given a keyset pagination with an absent incoming cursor and a page size of two */
+ $pagination = Pagination::from(perPage: 2, cursor: Token::none());
+
+ /** @And a cursor page built over items fetched for the page size plus one */
+ $page = Page::from(
+ sort: $this->sort,
+ filter: $this->filter,
+ items: [10, 20, 30],
+ keysOf: static fn(int $element): array => [$element],
+ pagination: $pagination
+ );
+
+ /** @Then the page reports a next page */
+ self::assertTrue($page->hasNext());
+
+ /** @And the items are trimmed to the page size */
+ self::assertSame([10, 20], $page->items()->toArray());
+
+ /** @And the next pagination anchors on the last retained element with the page size */
+ self::assertEquals(Pagination::from(perPage: 2, cursor: Token::fromKeys(keys: [20])), $page->next());
+
+ /** @And the metadata carries the navigation flags in length-ascending key order */
+ self::assertSame(['has_next' => true, 'per_page' => 2], $page->metadata());
+ }
+}
diff --git a/tests/Unit/CursorPaginationTest.php b/tests/Unit/Cursor/PaginationTest.php
similarity index 79%
rename from tests/Unit/CursorPaginationTest.php
rename to tests/Unit/Cursor/PaginationTest.php
index afac42e..fea673a 100644
--- a/tests/Unit/CursorPaginationTest.php
+++ b/tests/Unit/Cursor/PaginationTest.php
@@ -2,19 +2,19 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\HttpQuery\Unit;
+namespace Test\TinyBlocks\HttpQuery\Unit\Cursor;
use PHPUnit\Framework\TestCase;
-use TinyBlocks\HttpQuery\Cursor;
-use TinyBlocks\HttpQuery\CursorPagination;
+use TinyBlocks\HttpQuery\Cursor\Pagination;
+use TinyBlocks\HttpQuery\Cursor\Token;
use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange;
-final class CursorPaginationTest extends TestCase
+final class PaginationTest extends TestCase
{
public function testFromWhenPageSizeIsTheMinimumThenCarriesThatLimit(): void
{
/** @Given a cursor pagination built from an absent cursor and the minimum page size of 1 */
- $pagination = CursorPagination::from(cursor: Cursor::none(), perPage: 1);
+ $pagination = Pagination::from(perPage: 1, cursor: Token::none());
/** @When reading the page size limit */
$limit = $pagination->limit();
@@ -26,7 +26,7 @@ public function testFromWhenPageSizeIsTheMinimumThenCarriesThatLimit(): void
public function testToQueryStringWhenCursorAbsentThenRendersOnlyTheSize(): void
{
/** @Given a cursor pagination carrying an absent cursor and a page size */
- $pagination = CursorPagination::from(cursor: Cursor::none(), perPage: 10);
+ $pagination = Pagination::from(perPage: 10, cursor: Token::none());
/** @When rendering it as a query string */
$queryString = $pagination->toQueryString();
@@ -38,7 +38,7 @@ public function testToQueryStringWhenCursorAbsentThenRendersOnlyTheSize(): void
public function testFromWhenAbsentCursorGivenThenCarriesLimitAndAbsentCursor(): void
{
/** @Given a cursor pagination built from an absent cursor and a page size of 20 */
- $pagination = CursorPagination::from(cursor: Cursor::none(), perPage: 20);
+ $pagination = Pagination::from(perPage: 20, cursor: Token::none());
/** @When reading the page size limit */
$limit = $pagination->limit();
@@ -53,19 +53,19 @@ public function testFromWhenAbsentCursorGivenThenCarriesLimitAndAbsentCursor():
public function testFromWhenPageSizeBelowMinimumThenThrowsPageSizeOutOfRange(): void
{
/** @Given an absent cursor */
- $cursor = Cursor::none();
+ $cursor = Token::none();
/** @Then an exception indicating the page size is out of range is raised */
$this->expectException(PageSizeOutOfRange::class);
/** @When building a cursor pagination with a page size below the minimum */
- CursorPagination::from(cursor: $cursor, perPage: 0);
+ Pagination::from(perPage: 0, cursor: $cursor);
}
public function testToQueryStringWhenCursorPresentThenRendersCursorAndSize(): void
{
/** @Given a cursor pagination carrying an incoming cursor and a page size */
- $pagination = CursorPagination::from(cursor: Cursor::from(token: 'abc'), perPage: 10);
+ $pagination = Pagination::from(perPage: 10, cursor: Token::from(token: 'abc'));
/** @When rendering it as a query string */
$queryString = $pagination->toQueryString();
@@ -77,7 +77,7 @@ public function testToQueryStringWhenCursorPresentThenRendersCursorAndSize(): vo
public function testFromWhenIncomingCursorGivenThenCarriesLimitAndTheCursorToken(): void
{
/** @Given a cursor pagination built from an incoming cursor and a page size of 50 */
- $pagination = CursorPagination::from(cursor: Cursor::from(token: 'abc'), perPage: 50);
+ $pagination = Pagination::from(perPage: 50, cursor: Token::from(token: 'abc'));
/** @When reading the page size limit */
$limit = $pagination->limit();
diff --git a/tests/Unit/Cursor/TokenTest.php b/tests/Unit/Cursor/TokenTest.php
new file mode 100644
index 0000000..875e322
--- /dev/null
+++ b/tests/Unit/Cursor/TokenTest.php
@@ -0,0 +1,206 @@
+isAbsent();
+
+ /** @Then it reports itself as present */
+ self::assertFalse($isAbsent);
+ }
+
+ public function testFromWhenEmptyTokenGivenThenIsAbsent(): void
+ {
+ /** @Given a token backed by an empty incoming string */
+ $token = Token::from(token: '');
+
+ /** @When inspecting whether the token is absent */
+ $isAbsent = $token->isAbsent();
+
+ /** @Then it reports itself as absent */
+ self::assertTrue($isAbsent);
+ }
+
+ public function testFromWhenNonEmptyTokenGivenThenIsPresent(): void
+ {
+ /** @Given a token backed by a non-empty incoming string */
+ $token = Token::from(token: 'abc');
+
+ /** @When inspecting whether the token is absent */
+ $isAbsent = $token->isAbsent();
+
+ /** @Then it reports itself as present */
+ self::assertFalse($isAbsent);
+ }
+
+ public function testNoneThenAbsentWithEmptyKeysAndEmptyToken(): void
+ {
+ /** @Given an absent token */
+ $token = Token::none();
+
+ /** @When inspecting the absent token */
+ $isAbsent = $token->isAbsent();
+
+ /** @Then it reports itself as absent */
+ self::assertTrue($isAbsent);
+
+ /** @And it decodes to no key values */
+ self::assertSame([], $token->toArray());
+
+ /** @And it renders an empty string */
+ self::assertSame('', $token->toString());
+ }
+
+ public function testFromKeysWhenKeysGivenThenDecodesToTheSameKeys(): void
+ {
+ /** @Given a token backed by ordering key values */
+ $token = Token::fromKeys(keys: ['2024-01-01', 42]);
+
+ /** @When decoding the token into key values */
+ $keys = $token->toArray();
+
+ /** @Then it yields the original key values */
+ self::assertSame(['2024-01-01', 42], $keys);
+ }
+
+ public function testFromKeysWhenKeysAreGappedThenDecodesToAReindexedList(): void
+ {
+ /** @Given a token backed by ordering key values held under gapped integer keys */
+ $token = Token::fromKeys(keys: [5 => 'x', 9 => 'y']);
+
+ /** @When decoding the token into key values */
+ $keys = $token->toArray();
+
+ /** @Then it yields the values as a zero-based list */
+ self::assertSame(['x', 'y'], $keys);
+ }
+
+ public function testKeyedByWhenTokenAbsentThenEveryFieldIsNull(): void
+ {
+ /** @Given an absent token */
+ $token = Token::none();
+
+ /** @When keying the decoded values by the sort field names */
+ $keyed = $token->keyedBy(fields: ['created_at', 'id']);
+
+ /** @Then every field is present with a null value */
+ self::assertSame(['created_at' => null, 'id' => null], $keyed);
+ }
+
+ public function testKeyedByWhenKeysPresentThenValuesAreKeyedByField(): void
+ {
+ /** @Given a token backed by ordering key values */
+ $token = Token::fromKeys(keys: ['2024-01-01', 42]);
+
+ /** @When keying the decoded values by the sort field names */
+ $keyed = $token->keyedBy(fields: ['created_at', 'id']);
+
+ /** @Then the values are keyed by the field names in order */
+ self::assertSame(['created_at' => '2024-01-01', 'id' => 42], $keyed);
+ }
+
+ public function testKeyedByWhenCountMismatchesThenThrowsCursorIsInvalid(): void
+ {
+ /** @Given a token backed by a single key value */
+ $token = Token::fromKeys(keys: [5]);
+
+ /** @Then an exception indicating the cursor token is invalid is raised */
+ $this->expectException(CursorIsInvalid::class);
+
+ /** @When keying the decoded values by two field names */
+ $token->keyedBy(fields: ['created_at', 'id']);
+ }
+
+ public function testToArrayWhenTokenCarriesInvalidCharacterThenThrowsCursorIsInvalid(): void
+ {
+ /** @Given a token carrying a character outside the base64url alphabet */
+ $token = Token::from(token: 'WzEsMl0!');
+
+ /** @Then an exception indicating the cursor token is invalid is raised */
+ $this->expectException(CursorIsInvalid::class);
+ $this->expectExceptionMessage('could not be decoded');
+
+ /** @When decoding the token into key values */
+ $token->toArray();
+ }
+
+ public function testToArrayWhenTokenDecodesToScalarThenThrowsCursorIsInvalid(): void
+ {
+ /** @Given a base64url token whose decoded payload is the JSON scalar 5 */
+ $token = Token::from(token: rtrim(strtr(base64_encode('5'), '+/', '-_'), '='));
+
+ /** @Then an exception indicating the cursor token is invalid is raised */
+ $this->expectException(CursorIsInvalid::class);
+ $this->expectExceptionMessage('could not be decoded');
+
+ /** @When decoding the token into key values */
+ $token->toArray();
+ }
+
+ public function testFromKeysWhenRoundTrippedThroughTokenThenYieldsTheOriginalKeys(): void
+ {
+ /** @Given the opaque token produced from ordering key values */
+ $token = Token::fromKeys(keys: ['2024-01-01', 42])->toString();
+
+ /** @And the token is not empty */
+ self::assertNotSame('', $token);
+
+ /** @When rebuilding a token from that string and decoding it */
+ $keys = Token::from(token: $token)->toArray();
+
+ /** @Then it yields the original key values */
+ self::assertSame(['2024-01-01', 42], $keys);
+ }
+
+ public function testFromKeysWhenEncodedThenTokenIsUrlSafeAndUnpadded(): void
+ {
+ /** @Given a token backed by key values whose base64 payload carries plus, slash, and padding */
+ $token = Token::fromKeys(keys: ['>>>???']);
+
+ /** @When rendering the opaque token */
+ $rendered = $token->toString();
+
+ /** @Then it renders the base64url form, translating plus and slash and dropping the padding */
+ self::assertSame('WyI-Pj4_Pz8iXQ', $rendered);
+ }
+
+ public function testFromKeysWhenRoundTrippedThroughUrlSafeTokenThenYieldsTheOriginalKeys(): void
+ {
+ /** @Given the opaque token produced from key values whose base64 payload carries plus and slash */
+ $token = Token::fromKeys(keys: ['>>>???'])->toString();
+
+ /** @When rebuilding a token from that URL-safe string and decoding it */
+ $keys = Token::from(token: $token)->toArray();
+
+ /** @Then it yields the original key values */
+ self::assertSame(['>>>???'], $keys);
+ }
+
+ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheStaticOnlyCodec(): void
+ {
+ /** @Given an uninitialized instance of the static-only cursor codec */
+ $codec = new ReflectionClass(CursorCodec::class)->newInstanceWithoutConstructor();
+
+ /** @When invoking its otherwise-uncallable private constructor */
+ new ReflectionMethod(CursorCodec::class, '__construct')->invoke($codec);
+
+ /** @Then the static-only cursor codec is instantiated */
+ self::assertInstanceOf(CursorCodec::class, $codec);
+ }
+}
diff --git a/tests/Unit/CursorPageTest.php b/tests/Unit/CursorPageTest.php
deleted file mode 100644
index 8d1cafa..0000000
--- a/tests/Unit/CursorPageTest.php
+++ /dev/null
@@ -1,204 +0,0 @@
-criteria = Criteria::fromQuery(request: Query::from(parameters: []));
- }
-
- public function testToResponseWhenCursorPageGivenThenRendersBodyAndLinkHeader(): void
- {
- /** @Given an opaque token produced from ordering key values */
- $token = Cursor::fromKeys(keys: [5])->toString();
-
- /** @And query parameters carrying that cursor and a page size of two */
- $query = Query::from(parameters: ['page' => ['cursor' => $token, 'size' => '2']]);
-
- /** @And the criteria parsed from those parameters */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @And a cursor page built from the criteria over items fetched for the page size plus one */
- $page = $criteria->cursorPage(items: [10, 20, 30], keysOf: static fn(mixed $element): array => [$element]);
-
- /** @When rendering the cursor page as a JSON:API response over the orders base URI */
- $response = $page->toResponse(baseUri: '/v1/orders');
-
- /** @Then the response body carries the trimmed data, the meta, and the keyset links */
- self::assertSame([
- 'data' => [10, 20],
- 'meta' => [
- 'has_next' => true,
- 'per_page' => 2,
- 'has_previous' => true
- ],
- 'links' => [
- 'self' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', $token),
- 'prev' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', Cursor::fromKeys(keys: [10])->toString()),
- 'next' => sprintf('/v1/orders?page[cursor]=%s&page[size]=2', Cursor::fromKeys(keys: [20])->toString())
- ]
- ], json_decode($response->getBody()->getContents(), true));
-
- /** @And the Link header folds the self, previous, and next relations */
- self::assertSame(implode(', ', [
- sprintf('; rel="self"', $token),
- sprintf('; rel="prev"', Cursor::fromKeys(keys: [10])->toString()),
- sprintf('; rel="next"', Cursor::fromKeys(keys: [20])->toString())
- ]), $response->getHeaderLine('Link'));
- }
-
- public function testNavigationWhenExtraElementFetchedThenListsOnlyTheNextTarget(): void
- {
- /** @Given a keyset page fetched for the page size plus one with an absent incoming cursor */
- $page = CursorPage::from(
- items: Collection::createFrom(elements: [10, 20, 30]),
- keysOf: static fn(mixed $element): array => [$element],
- criteria: $this->criteria,
- pagination: CursorPagination::from(cursor: Cursor::none(), perPage: 2)
- );
-
- /** @When reading the navigation targets */
- $targets = $page->navigation()->targets();
-
- /** @Then it lists a single navigation target */
- self::assertCount(1, $targets->toArray());
-
- /** @And the only target is the next target anchored on the forward cursor */
- self::assertEquals(
- NavigationTarget::to(
- target: CursorPagination::from(cursor: Cursor::fromKeys(keys: [20]), perPage: 2),
- relation: LinkRelation::NEXT
- ),
- $targets->first()
- );
- }
-
- public function testNavigationWhenExtraElementFetchedThenHasNextAndForwardCursorAnchorsLastItem(): void
- {
- /** @Given a keyset pagination with an absent incoming cursor and a page size of two */
- $pagination = CursorPagination::from(cursor: Cursor::none(), perPage: 2);
-
- /** @And items fetched for the page size plus one */
- $items = Collection::createFrom(elements: [10, 20, 30]);
-
- /** @When building the cursor page from the items, the key extractor, and the pagination */
- $page = CursorPage::from(
- items: $items,
- keysOf: static fn(mixed $element): array => [$element],
- criteria: $this->criteria,
- pagination: $pagination
- );
-
- /** @Then the page reports a next page */
- self::assertTrue($page->hasNext());
-
- /** @And the items are trimmed to the page size */
- self::assertSame([10, 20], $page->items()->toArray());
-
- /** @And the page has no previous page */
- self::assertFalse($page->hasPrevious());
-
- /** @And the next pagination anchors on the last retained element with the page size */
- self::assertEquals(
- CursorPagination::from(cursor: Cursor::fromKeys(keys: [20]), perPage: 2),
- $page->next()
- );
-
- /** @And there is no previous pagination */
- self::assertNull($page->previous());
-
- /** @And the metadata carries every navigation flag in length-ascending key order */
- self::assertSame([
- 'has_next' => true,
- 'per_page' => 2,
- 'has_previous' => false
- ], $page->metadata());
- }
-
- public function testNavigationWhenIncomingCursorAndNoExtraElementThenListsOnlyThePreviousTarget(): void
- {
- /** @Given the opaque token produced from ordering key values */
- $token = Cursor::fromKeys(keys: [5])->toString();
-
- /** @And a keyset page fetched within the page size reached through that incoming cursor */
- $page = CursorPage::from(
- items: Collection::createFrom(elements: [10, 20]),
- keysOf: static fn(mixed $element): array => [$element],
- criteria: $this->criteria,
- pagination: CursorPagination::from(cursor: Cursor::from(token: $token), perPage: 2)
- );
-
- /** @When reading the navigation targets */
- $targets = $page->navigation()->targets();
-
- /** @Then it lists a single navigation target */
- self::assertCount(1, $targets->toArray());
-
- /** @And the only target is the previous target anchored on the backward cursor */
- self::assertEquals(
- NavigationTarget::to(
- target: CursorPagination::from(cursor: Cursor::fromKeys(keys: [10]), perPage: 2),
- relation: LinkRelation::PREVIOUS
- ),
- $targets->first()
- );
- }
-
- public function testNavigationWhenNoExtraElementAndIncomingCursorThenNoNextAndBackwardCursorAnchorsFirstItem(): void
- {
- /** @Given the opaque token produced from ordering key values */
- $existingToken = Cursor::fromKeys(keys: [5])->toString();
-
- /** @And a keyset pagination with a present incoming cursor and a page size of two */
- $pagination = CursorPagination::from(cursor: Cursor::from(token: $existingToken), perPage: 2);
-
- /** @And items fetched within the page size */
- $items = Collection::createFrom(elements: [10, 20]);
-
- /** @When building the cursor page from the items, the key extractor, and the pagination */
- $page = CursorPage::from(
- items: $items,
- keysOf: static fn(mixed $element): array => [$element],
- criteria: $this->criteria,
- pagination: $pagination
- );
-
- /** @Then the page reports no next page */
- self::assertFalse($page->hasNext());
-
- /** @And the page has a previous page */
- self::assertTrue($page->hasPrevious());
-
- /** @And there is no next pagination */
- self::assertNull($page->next());
-
- /** @And the previous pagination anchors on the first retained element with the page size */
- self::assertEquals(
- CursorPagination::from(cursor: Cursor::fromKeys(keys: [10]), perPage: 2),
- $page->previous()
- );
-
- /** @And the metadata carries every navigation flag in length-ascending key order */
- self::assertSame([
- 'has_next' => false,
- 'per_page' => 2,
- 'has_previous' => true
- ], $page->metadata());
- }
-}
diff --git a/tests/Unit/CursorTest.php b/tests/Unit/CursorTest.php
deleted file mode 100644
index 7f8d945..0000000
--- a/tests/Unit/CursorTest.php
+++ /dev/null
@@ -1,147 +0,0 @@
-isAbsent();
-
- /** @Then it reports itself as present */
- self::assertFalse($isAbsent);
- }
-
- public function testFromWhenEmptyTokenGivenThenIsAbsent(): void
- {
- /** @Given a cursor backed by an empty incoming token */
- $cursor = Cursor::from(token: '');
-
- /** @When inspecting whether the cursor is absent */
- $isAbsent = $cursor->isAbsent();
-
- /** @Then it reports itself as absent */
- self::assertTrue($isAbsent);
- }
-
- public function testFromWhenNonEmptyTokenGivenThenIsPresent(): void
- {
- /** @Given a cursor backed by a non-empty incoming token */
- $cursor = Cursor::from(token: 'abc');
-
- /** @When inspecting whether the cursor is absent */
- $isAbsent = $cursor->isAbsent();
-
- /** @Then it reports itself as present */
- self::assertFalse($isAbsent);
- }
-
- public function testNoneThenAbsentWithEmptyKeysAndEmptyToken(): void
- {
- /** @Given an absent cursor */
- $cursor = Cursor::none();
-
- /** @When inspecting the absent cursor */
- $isAbsent = $cursor->isAbsent();
-
- /** @Then it reports itself as absent */
- self::assertTrue($isAbsent);
-
- /** @And it decodes to no key values */
- self::assertSame([], $cursor->toArray());
-
- /** @And it renders an empty token */
- self::assertSame('', $cursor->toString());
- }
-
- public function testFromKeysWhenKeysGivenThenDecodesToTheSameKeys(): void
- {
- /** @Given a cursor backed by ordering key values */
- $cursor = Cursor::fromKeys(keys: ['2024-01-01', 42]);
-
- /** @When decoding the cursor into key values */
- $keys = $cursor->toArray();
-
- /** @Then it yields the original key values */
- self::assertSame(['2024-01-01', 42], $keys);
- }
-
- public function testFromKeysWhenKeysAreGappedThenDecodesToAReindexedList(): void
- {
- /** @Given a cursor backed by ordering key values held under gapped integer keys */
- $cursor = Cursor::fromKeys(keys: [5 => 'x', 9 => 'y']);
-
- /** @When decoding the cursor into key values */
- $keys = $cursor->toArray();
-
- /** @Then it yields the values as a zero-based list */
- self::assertSame(['x', 'y'], $keys);
- }
-
- public function testToArrayWhenTokenIsNotBase62ThenThrowsCursorIsInvalid(): void
- {
- /** @Given a cursor backed by a token carrying characters outside the base62 alphabet */
- $cursor = Cursor::from(token: 'not valid base62 !!!');
-
- /** @Then an exception indicating the cursor token is invalid is raised */
- $this->expectException(CursorIsInvalid::class);
- $this->expectExceptionMessage('could not be decoded');
-
- /** @When decoding the cursor into key values */
- $cursor->toArray();
- }
-
- public function testToArrayWhenTokenDecodesToScalarThenThrowsCursorIsInvalid(): void
- {
- /** @Given a base62 token whose decoded payload is the JSON scalar 5 */
- $cursor = Cursor::from(token: Base62::from(value: '5')->encode());
-
- /** @Then an exception indicating the cursor token is invalid is raised */
- $this->expectException(CursorIsInvalid::class);
- $this->expectExceptionMessage('could not be decoded');
-
- /** @When decoding the cursor into key values */
- $cursor->toArray();
- }
-
- public function testFromKeysWhenRoundTrippedThroughTokenThenYieldsTheOriginalKeys(): void
- {
- /** @Given the opaque token produced from ordering key values */
- $token = Cursor::fromKeys(keys: ['2024-01-01', 42])->toString();
-
- /** @And the token is not empty */
- self::assertNotSame('', $token);
-
- /** @When rebuilding a cursor from that token and decoding it */
- $keys = Cursor::from(token: $token)->toArray();
-
- /** @Then it yields the original key values */
- self::assertSame(['2024-01-01', 42], $keys);
- }
-
- public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheStaticOnlyCodec(): void
- {
- /** @Given an uninitialized instance of the static-only cursor codec */
- $codec = new ReflectionClass(CursorCodec::class)->newInstanceWithoutConstructor();
-
- /** @When invoking its otherwise-uncallable private constructor */
- new ReflectionMethod(CursorCodec::class, '__construct')->invoke($codec);
-
- /** @Then the static-only cursor codec is instantiated */
- self::assertInstanceOf(CursorCodec::class, $codec);
- }
-}
diff --git a/tests/Unit/EndToEndTest.php b/tests/Unit/EndToEndTest.php
index b648b34..d709242 100644
--- a/tests/Unit/EndToEndTest.php
+++ b/tests/Unit/EndToEndTest.php
@@ -6,19 +6,23 @@
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\HttpQuery\Models\Query;
-use TinyBlocks\Collection\Collection;
-use TinyBlocks\Collection\KeyPreservation;
-use TinyBlocks\HttpQuery\Criteria;
-use TinyBlocks\HttpQuery\Cursor;
-use TinyBlocks\HttpQuery\Links;
-use TinyBlocks\HttpQuery\OffsetPage;
-use TinyBlocks\HttpQuery\OffsetPagination;
+use TinyBlocks\HttpQuery\Cursor\Criteria as CursorCriteria;
+use TinyBlocks\HttpQuery\Cursor\Token;
+use TinyBlocks\HttpQuery\Offset\Criteria as OffsetCriteria;
+use TinyBlocks\HttpQuery\Operator;
+use TinyBlocks\HttpQuery\Schema;
final class EndToEndTest extends TestCase
{
- public function testPipelineWhenRequestGivenThenOffsetPageRendersResponse(): void
+ public function testPipelineWhenOffsetRequestGivenThenPageRendersTheResponse(): void
{
- /** @Given the canonical request query parameters */
+ /** @Given the query contract of the orders endpoint */
+ $schema = Schema::create()
+ ->sortable(fields: ['created_at', 'id'])
+ ->filterable(field: 'total', operators: [Operator::GREATER_THAN_OR_EQUAL])
+ ->filterable(field: 'status', operators: [Operator::EQUAL]);
+
+ /** @And the canonical request query parameters */
$query = Query::from(parameters: [
'filter' => 'status==paid;total=ge=100',
'sort' => '-created_at,id',
@@ -26,13 +30,13 @@ public function testPipelineWhenRequestGivenThenOffsetPageRendersResponse(): voi
]);
/** @And the criteria parsed from those parameters */
- $criteria = Criteria::fromQuery(request: $query);
+ $criteria = OffsetCriteria::fromQuery(request: $query, schema: $schema);
/** @And the base URI the navigation links render against */
$base = '/v1/orders?filter=status==paid;total=ge=100&sort=-created_at,id';
/** @And the third page of a 480-element result built from the criteria */
- $page = $criteria->offsetPage(items: ['a', 'b'], total: 480);
+ $page = $criteria->page(total: 480, items: ['a', 'b']);
/** @When rendering the page as a JSON:API response over the orders base URI */
$response = $page->toResponse(baseUri: '/v1/orders');
@@ -58,45 +62,15 @@ public function testPipelineWhenRequestGivenThenOffsetPageRendersResponse(): voi
], json_decode($response->getBody()->getContents(), true));
}
- public function testPipelineWhenCursorRequestGivenThenCursorPageRendersResponse(): void
+ public function testPipelineWhenOffsetRequestGivenThenLinkHeaderFoldsEveryRelation(): void
{
- /** @Given an opaque token produced from ordering key values */
- $token = Cursor::fromKeys(keys: [5])->toString();
-
- /** @And query parameters carrying a sort, that cursor, and a page size of two */
- $query = Query::from(parameters: ['sort' => 'id', 'page' => ['cursor' => $token, 'size' => '2']]);
-
- /** @And the criteria parsed from those parameters */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @And a cursor page built from the criteria over items fetched for the page size plus one */
- $page = $criteria->cursorPage(items: [10, 20, 30], keysOf: static fn(mixed $element): array => [$element]);
-
- /** @And the base URI the keyset links render against */
- $base = '/v1/orders?sort=id';
-
- /** @When rendering the cursor page as a JSON:API response over the orders base URI */
- $response = $page->toResponse(baseUri: '/v1/orders');
-
- /** @Then the body carries the data, the meta, and the keyset links preserving the sort */
- self::assertSame([
- 'data' => [10, 20],
- 'meta' => [
- 'has_next' => true,
- 'per_page' => 2,
- 'has_previous' => true
- ],
- 'links' => [
- 'self' => sprintf('%s&page[cursor]=%s&page[size]=2', $base, $token),
- 'prev' => sprintf('%s&page[cursor]=%s&page[size]=2', $base, Cursor::fromKeys(keys: [10])->toString()),
- 'next' => sprintf('%s&page[cursor]=%s&page[size]=2', $base, Cursor::fromKeys(keys: [20])->toString())
- ]
- ], json_decode($response->getBody()->getContents(), true));
- }
+ /** @Given the query contract of the orders endpoint */
+ $schema = Schema::create()
+ ->sortable(fields: ['created_at', 'id'])
+ ->filterable(field: 'total', operators: [Operator::GREATER_THAN_OR_EQUAL])
+ ->filterable(field: 'status', operators: [Operator::EQUAL]);
- public function testPipelineWhenCanonicalRequestGivenThenLinkHeaderFoldsEveryRelation(): void
- {
- /** @Given the canonical request query parameters */
+ /** @And the canonical request query parameters */
$query = Query::from(parameters: [
'filter' => 'status==paid;total=ge=100',
'sort' => '-created_at,id',
@@ -104,21 +78,7 @@ public function testPipelineWhenCanonicalRequestGivenThenLinkHeaderFoldsEveryRel
]);
/** @And the criteria parsed from those parameters */
- $criteria = Criteria::fromQuery(request: $query);
-
- /** @And the offset pagination pointing at the third page */
- $pagination = OffsetPagination::fromPage(page: 3, perPage: 20);
-
- /** @And the third page of a 480-element result */
- $page = OffsetPage::from(
- items: Collection::createFromEmpty(),
- total: 480,
- criteria: $criteria,
- pagination: $pagination
- );
-
- /** @And the navigation for that page over the orders base URI */
- $links = Links::from(baseUri: '/v1/orders', criteria: $criteria, navigation: $page->navigation());
+ $criteria = OffsetCriteria::fromQuery(request: $query, schema: $schema);
/** @And the base URI the relations render against */
$base = '/v1/orders?filter=status==paid;total=ge=100&sort=-created_at,id';
@@ -126,8 +86,8 @@ public function testPipelineWhenCanonicalRequestGivenThenLinkHeaderFoldsEveryRel
/** @And the Link header template rendered per relation */
$template = '<%s&page[number]=%d&page[size]=20>; rel="%s"';
- /** @When rendering the RFC 8288 Link header line */
- $header = $links->toHeader()->toArray()['Link'][0];
+ /** @When rendering the page as a JSON:API response and reading its RFC 8288 Link header line */
+ $header = $criteria->page(total: 480, items: [])->toResponse(baseUri: '/v1/orders')->getHeaderLine('Link');
/** @Then the line folds the five relations in navigation order */
self::assertSame(implode(', ', [
@@ -139,60 +99,39 @@ public function testPipelineWhenCanonicalRequestGivenThenLinkHeaderFoldsEveryRel
]), $header);
}
- public function testPipelineWhenCanonicalRequestGivenThenJsonBodyCarriesDataMetaAndLinks(): void
+ public function testPipelineWhenCursorRequestGivenThenCursorPageRendersTheResponse(): void
{
- /** @Given the canonical request query parameters */
- $query = Query::from(parameters: [
- 'filter' => 'status==paid;total=ge=100',
- 'sort' => '-created_at,id',
- 'page' => ['number' => '3', 'size' => '20']
- ]);
-
- /** @And the criteria parsed from those parameters */
- $criteria = Criteria::fromQuery(request: $query);
+ /** @Given the query contract of the orders endpoint */
+ $schema = Schema::create()->sortable(fields: ['id']);
- /** @And the offset pagination pointing at the third page */
- $pagination = OffsetPagination::fromPage(page: 3, perPage: 20);
+ /** @And an opaque token produced from ordering key values */
+ $token = Token::fromKeys(keys: [5])->toString();
- /** @And the third page of a 480-element result */
- $page = OffsetPage::from(
- items: Collection::createFromEmpty(),
- total: 480,
- criteria: $criteria,
- pagination: $pagination
- );
+ /** @And query parameters carrying a sort, that cursor, and a page size of two */
+ $query = Query::from(parameters: ['sort' => 'id', 'page' => ['cursor' => $token, 'size' => '2']]);
- /** @And the navigation for that page over the orders base URI */
- $links = Links::from(baseUri: '/v1/orders', criteria: $criteria, navigation: $page->navigation());
+ /** @And a cursor page built through the keyset view over the array rows fetched */
+ $page = CursorCriteria::fromQuery(request: $query, schema: $schema)
+ ->keyset()
+ ->page(items: [['id' => 10], ['id' => 20], ['id' => 30]]);
- /** @And the base URI the navigation links render against */
- $base = '/v1/orders?filter=status==paid;total=ge=100&sort=-created_at,id';
+ /** @And the base URI the keyset links render against */
+ $base = '/v1/orders?sort=id';
- /** @When assembling the JSON:API body from the data, the meta, and the links */
- $body = [
- 'data' => $page->items()->toArray(keyPreservation: KeyPreservation::DISCARD),
- 'meta' => $page->metadata(),
- 'links' => $links->toArray()
- ];
+ /** @When rendering the cursor page as a JSON:API response over the orders base URI */
+ $response = $page->toResponse(baseUri: '/v1/orders');
- /** @Then the body carries the empty data, the meta, and the five navigation links in order */
+ /** @Then the body carries the data, the meta, and the forward-only keyset links preserving the sort */
self::assertSame([
- 'data' => [],
+ 'data' => [['id' => 10], ['id' => 20]],
'meta' => [
- 'total' => 480,
- 'has_next' => true,
- 'per_page' => 20,
- 'total_pages' => 24,
- 'current_page' => 3,
- 'has_previous' => true
+ 'has_next' => true,
+ 'per_page' => 2
],
'links' => [
- 'self' => sprintf('%s&page[number]=3&page[size]=20', $base),
- 'first' => sprintf('%s&page[number]=1&page[size]=20', $base),
- 'prev' => sprintf('%s&page[number]=2&page[size]=20', $base),
- 'next' => sprintf('%s&page[number]=4&page[size]=20', $base),
- 'last' => sprintf('%s&page[number]=24&page[size]=20', $base)
+ 'self' => sprintf('%s&page[cursor]=%s&page[size]=2', $base, $token),
+ 'next' => sprintf('%s&page[cursor]=%s&page[size]=2', $base, Token::fromKeys(keys: [20])->toString())
]
- ], $body);
+ ], json_decode($response->getBody()->getContents(), true));
}
}
diff --git a/tests/Unit/ExpressionTest.php b/tests/Unit/ExpressionTest.php
deleted file mode 100644
index 26d4b8c..0000000
--- a/tests/Unit/ExpressionTest.php
+++ /dev/null
@@ -1,78 +0,0 @@
-value();
-
- /** @Then it returns the rendered leaf */
- self::assertSame('status==paid', $actual);
- }
-
- public function testValueWhenGroupedGivenThenReturnsJoinedChildren(): void
- {
- /** @Given a grouped expression rendered from children joined by a connective */
- $expression = Expression::grouped(value: 'a==1;b==2', connective: LogicalOperator::AND);
-
- /** @When reading the RSQL expression */
- $actual = $expression->value();
-
- /** @Then it returns the joined children */
- self::assertSame('a==1;b==2', $actual);
- }
-
- #[DataProvider('nestingCases')]
- public function testNestedWithinWhenNestedThenParenthesizesByPrecedence(
- Expression $expression,
- LogicalOperator $parent,
- string $expected
- ): void {
- /** @Given a rendered expression and the connective it is nested within */
-
- /** @When rendering it nested within the parent connective */
- $actual = $expression->nestedWithin(parent: $parent);
-
- /** @Then it is parenthesized only when it binds looser than the parent */
- self::assertSame($expected, $actual);
- }
-
- public static function nestingCases(): array
- {
- return [
- 'OR grouped within AND' => [
- 'expression' => Expression::grouped(value: 'a==1,b==2', connective: LogicalOperator::OR),
- 'parent' => LogicalOperator::AND,
- 'expected' => '(a==1,b==2)'
- ],
- 'AND grouped within OR' => [
- 'expression' => Expression::grouped(value: 'a==1;b==2', connective: LogicalOperator::AND),
- 'parent' => LogicalOperator::OR,
- 'expected' => 'a==1;b==2'
- ],
- 'OR grouped within OR' => [
- 'expression' => Expression::grouped(value: 'a==1,b==2', connective: LogicalOperator::OR),
- 'parent' => LogicalOperator::OR,
- 'expected' => 'a==1,b==2'
- ],
- 'atomic within AND' => [
- 'expression' => Expression::atomic(value: 'a==1'),
- 'parent' => LogicalOperator::AND,
- 'expected' => 'a==1'
- ]
- ];
- }
-}
diff --git a/tests/Unit/FilterTest.php b/tests/Unit/FilterTest.php
index 47733a2..b425a72 100644
--- a/tests/Unit/FilterTest.php
+++ b/tests/Unit/FilterTest.php
@@ -8,306 +8,303 @@
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\HttpQuery\Models\Query;
use TinyBlocks\HttpQuery\Comparison;
-use TinyBlocks\HttpQuery\Criteria;
use TinyBlocks\HttpQuery\Exceptions\FilterExpressionIsInvalid;
-use TinyBlocks\HttpQuery\Group;
-use TinyBlocks\HttpQuery\LogicalOperator;
+use TinyBlocks\HttpQuery\Exceptions\FilterFieldNotAllowed;
+use TinyBlocks\HttpQuery\Exceptions\FilterOperatorNotAllowed;
+use TinyBlocks\HttpQuery\Exceptions\FilterShapeNotSupported;
+use TinyBlocks\HttpQuery\Exceptions\FilterValueNotAllowed;
+use TinyBlocks\HttpQuery\Offset\Criteria;
use TinyBlocks\HttpQuery\Operator;
+use TinyBlocks\HttpQuery\Schema;
+use TinyBlocks\HttpQuery\ValueKind;
final class FilterTest extends TestCase
{
- public function testIsEmptyWhenGroupHasNoChildThenIsEmpty(): void
- {
- /** @Given an empty group */
- $group = Group::none();
-
- /** @When checking whether it is empty */
- $isEmpty = $group->isEmpty();
-
- /** @Then it reports itself as empty */
- self::assertTrue($isEmpty);
- }
-
- public function testIsEmptyWhenGroupHasChildThenIsNotEmpty(): void
- {
- /** @Given a group joining a single comparison */
- $group = Group::of(filters: [
- Comparison::of(field: 'a', values: ['1'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::AND);
-
- /** @When checking whether it is empty */
- $isEmpty = $group->isEmpty();
+ private Schema $schema;
- /** @Then it reports itself as not empty */
- self::assertFalse($isEmpty);
- }
-
- public function testIsEmptyWhenComparisonGivenThenIsNotEmpty(): void
+ protected function setUp(): void
{
- /** @Given an equality comparison */
- $comparison = Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL);
-
- /** @When checking whether it is empty */
- $isEmpty = $comparison->isEmpty();
-
- /** @Then it reports itself as not empty */
- self::assertFalse($isEmpty);
+ $this->schema = Schema::create()
+ ->filterable(field: 'a', operators: Operator::cases())
+ ->filterable(field: 'b', operators: Operator::cases())
+ ->filterable(field: 'c', operators: Operator::cases())
+ ->filterable(field: 'role', operators: Operator::cases())
+ ->filterable(field: 'name', operators: Operator::cases())
+ ->filterable(field: 'status', operators: Operator::cases());
}
- public function testFilteringWhenNoFilterGivenThenReturnsEmptyGroup(): void
+ public function testFromQueryWhenNoFilterThenComparisonsAreEmpty(): void
{
/** @Given a query carrying no filter parameter at all */
$query = Query::from(parameters: []);
- /** @When reading the filtering specification */
- $filter = Criteria::fromQuery(request: $query)->filtering();
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $this->schema)->comparisons();
- /** @Then the filter is an empty group joined by the AND connective */
- self::assertEquals(Group::of(filters: [], operator: LogicalOperator::AND), $filter);
+ /** @Then there is no comparison */
+ self::assertSame([], $comparisons);
}
- public function testFilteringWhenOrGivenThenGroupJoinsTwoComparisons(): void
+ public function testFromQueryWhenSingleComparisonThenComparisonCarriesFieldAndValue(): void
{
- /** @Given a query carrying an OR expression of two comparisons */
- $query = Query::from(parameters: ['filter' => 'a==1,b==2']);
+ /** @Given a query carrying a single equality comparison */
+ $query = Query::from(parameters: ['filter' => 'status==paid']);
- /** @When reading the filtering specification */
- $filter = Criteria::fromQuery(request: $query)->filtering();
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $this->schema)->comparisons();
- /** @Then the filter is an OR group joining the two comparison children in order */
- self::assertEquals(Group::of(filters: [
- Comparison::of(field: 'a', values: ['1'], operator: Operator::EQUAL),
- Comparison::of(field: 'b', values: ['2'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::OR), $filter);
+ /** @Then the only comparison is an equality on the named field with its value */
+ self::assertEquals(
+ [Comparison::of(field: 'status', values: ['paid'], operator: Operator::EQUAL)],
+ $comparisons
+ );
}
- public function testFilteringWhenAndGivenThenGroupJoinsTwoComparisons(): void
+ public function testFromQueryWhenAndGroupThenComparisonsCarryEveryLeaf(): void
{
/** @Given a query carrying an AND expression of two comparisons */
$query = Query::from(parameters: ['filter' => 'a==1;b==2']);
- /** @When reading the filtering specification */
- $filter = Criteria::fromQuery(request: $query)->filtering();
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $this->schema)->comparisons();
- /** @Then the filter is an AND group joining the two comparison children in order */
- self::assertEquals(Group::of(filters: [
+ /** @Then the comparisons carry both leaves in order */
+ self::assertEquals([
Comparison::of(field: 'a', values: ['1'], operator: Operator::EQUAL),
Comparison::of(field: 'b', values: ['2'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::AND), $filter);
+ ], $comparisons);
}
- public function testFilteringWhenInListGivenThenComparisonCarriesEveryValue(): void
+ public function testFromQueryWhenInListThenComparisonCarriesEveryValue(): void
{
/** @Given a query carrying an IN list comparison */
$query = Query::from(parameters: ['filter' => 'role=in=(admin,user)']);
- /** @When reading the filtering specification */
- $filter = Criteria::fromQuery(request: $query)->filtering();
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $this->schema)->comparisons();
- /** @Then the filter is an IN comparison carrying every listed value in order */
- self::assertEquals(Comparison::of(field: 'role', values: ['admin', 'user'], operator: Operator::IN), $filter);
- }
-
- public function testToExpressionWhenAndGroupNestedInOrThenLeavesItUnwrapped(): void
- {
- /** @Given an OR group whose first child is an AND group */
- $group = Group::of(filters: [
- Group::of(filters: [
- Comparison::of(field: 'a', values: ['1'], operator: Operator::EQUAL),
- Comparison::of(field: 'b', values: ['2'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::AND),
- Comparison::of(field: 'c', values: ['3'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::OR);
-
- /** @When rendering it as an RSQL expression */
- $expression = $group->toExpression()->value();
-
- /** @Then the tighter-binding AND group is left unwrapped */
- self::assertSame('a==1;b==2,c==3', $expression);
+ /** @Then the only comparison is an IN comparison carrying every listed value in order */
+ self::assertEquals(
+ [Comparison::of(field: 'role', values: ['admin', 'user'], operator: Operator::IN)],
+ $comparisons
+ );
}
- public function testToExpressionWhenInListGivenThenWrapsValuesInParentheses(): void
+ public function testFromQueryWhenNotInListThenComparisonCarriesEveryValue(): void
{
- /** @Given an IN comparison carrying several values */
- $comparison = Comparison::of(field: 'role', values: ['admin', 'user'], operator: Operator::IN);
+ /** @Given a query carrying a NOT_IN list comparison */
+ $query = Query::from(parameters: ['filter' => 'role=out=(a,b)']);
- /** @When rendering it as an RSQL expression */
- $expression = $comparison->toExpression()->value();
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $this->schema)->comparisons();
- /** @Then it wraps the comma-joined values in parentheses */
- self::assertSame('role=in=(admin,user)', $expression);
+ /** @Then the only comparison is a NOT_IN comparison carrying every listed value in order */
+ self::assertEquals(
+ [Comparison::of(field: 'role', values: ['a', 'b'], operator: Operator::NOT_IN)],
+ $comparisons
+ );
}
- public function testToExpressionWhenValueCarriesReservedCharacterThenQuotesIt(): void
+ public function testFromQueryWhenDoubleQuotedValueThenComparisonStripsQuotes(): void
{
- /** @Given a comparison whose value carries a space */
- $comparison = Comparison::of(field: 'name', values: ['John Doe'], operator: Operator::EQUAL);
+ /** @Given a query carrying a double-quoted value with whitespace */
+ $query = Query::from(parameters: ['filter' => 'name=="John Doe"']);
- /** @When rendering it as an RSQL expression */
- $expression = $comparison->toExpression()->value();
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $this->schema)->comparisons();
- /** @Then it renders the value within double quotes */
- self::assertSame('name=="John Doe"', $expression);
+ /** @Then the comparison carries the value with the surrounding quotes stripped */
+ self::assertEquals(
+ [Comparison::of(field: 'name', values: ['John Doe'], operator: Operator::EQUAL)],
+ $comparisons
+ );
}
- public function testToExpressionWhenValueCarriesQuoteAndBackslashThenEscapesBoth(): void
+ public function testFromQueryWhenSingleQuotedValueThenComparisonStripsQuotes(): void
{
- /** @Given a comparison whose value carries a double quote and a backslash */
- $comparison = Comparison::of(field: 'name', values: ['a"b\\c'], operator: Operator::EQUAL);
+ /** @Given a query carrying a single-quoted value with whitespace */
+ $query = Query::from(parameters: ['filter' => "name=='John Doe'"]);
- /** @When rendering it as an RSQL expression */
- $expression = $comparison->toExpression()->value();
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $this->schema)->comparisons();
- /** @Then the quote and the backslash are escaped within the double-quoted value */
- self::assertSame('name=="a\\"b\\\\c"', $expression);
+ /** @Then the comparison carries the value with the surrounding quotes stripped */
+ self::assertEquals(
+ [Comparison::of(field: 'name', values: ['John Doe'], operator: Operator::EQUAL)],
+ $comparisons
+ );
}
- public function testFilteringWhenMixedPrecedenceGivenThenAndBindsTighterThanOr(): void
+ public function testFromQueryWhenDateTimeValueMatchesKindThenComparisonIsReturned(): void
{
- /** @Given a query mixing AND and OR connectives without explicit grouping */
- $query = Query::from(parameters: ['filter' => 'a==1;b==2,c==3']);
-
- /** @When reading the filtering specification */
- $filter = Criteria::fromQuery(request: $query)->filtering();
-
- /** @Then the top filter is an OR group whose first child is the tighter AND group */
- self::assertEquals(Group::of(filters: [
- Group::of(filters: [
- Comparison::of(field: 'a', values: ['1'], operator: Operator::EQUAL),
- Comparison::of(field: 'b', values: ['2'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::AND),
- Comparison::of(field: 'c', values: ['3'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::OR), $filter);
+ /** @Given a schema allowing a date-time field under the greater-than operator */
+ $schema = Schema::create()->filterable(
+ field: 'created_at',
+ operators: [Operator::GREATER_THAN],
+ kind: ValueKind::DATETIME
+ );
+
+ /** @And a query carrying a valid date-time value */
+ $query = Query::from(parameters: ['filter' => 'created_at=gt=2023-01-15T10:30:00Z']);
+
+ /** @When reading the validated comparisons */
+ $comparisons = Criteria::fromQuery(request: $query, schema: $schema)->comparisons();
+
+ /** @Then the only comparison carries the date-time value */
+ self::assertEquals([Comparison::of(
+ field: 'created_at',
+ values: ['2023-01-15T10:30:00Z'],
+ operator: Operator::GREATER_THAN
+ )], $comparisons);
}
- public function testFilteringWhenNotInListGivenThenComparisonCarriesEveryValue(): void
+ public function testFromQueryWhenOrGroupThenThrowsFilterShapeNotSupported(): void
{
- /** @Given a query carrying a NOT_IN list comparison */
- $query = Query::from(parameters: ['filter' => 'role=out=(a,b)']);
+ /** @Given a query carrying an OR expression of two comparisons */
+ $query = Query::from(parameters: ['filter' => 'a==1,b==2']);
- /** @When reading the filtering specification */
- $filter = Criteria::fromQuery(request: $query)->filtering();
+ /** @Then an exception carrying the raw filter query string is raised */
+ $this->expectException(FilterShapeNotSupported::class);
+ $this->expectExceptionMessage('Filter shape is not supported.');
- /** @Then the filter is a NOT_IN comparison carrying every listed value in order */
- self::assertEquals(Comparison::of(field: 'role', values: ['a', 'b'], operator: Operator::NOT_IN), $filter);
+ /** @When building the criteria from the query */
+ Criteria::fromQuery(request: $query, schema: $this->schema);
}
- public function testToExpressionWhenAndGroupGivenThenJoinsChildrenWithAndToken(): void
+ public function testFromQueryWhenMixedPrecedenceThenThrowsFilterShapeNotSupported(): void
{
- /** @Given an AND group of two comparisons */
- $group = Group::of(filters: [
- Comparison::of(field: 'a', values: ['1'], operator: Operator::EQUAL),
- Comparison::of(field: 'b', values: ['2'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::AND);
+ /** @Given a query mixing AND and OR connectives so the top filter is an OR group */
+ $query = Query::from(parameters: ['filter' => 'a==1;b==2,c==3']);
- /** @When rendering it as an RSQL expression */
- $expression = $group->toExpression()->value();
+ /** @Then an exception indicating the filter shape is not supported is raised */
+ $this->expectException(FilterShapeNotSupported::class);
+ $this->expectExceptionMessage('is not supported');
- /** @Then it joins the children with the AND token */
- self::assertSame('a==1;b==2', $expression);
+ /** @When building the criteria from the query */
+ Criteria::fromQuery(request: $query, schema: $this->schema);
}
- public function testToExpressionWhenOrGroupNestedInAndThenWrapsItInParentheses(): void
+ public function testFromQueryWhenNestedGroupThenThrowsFilterShapeNotSupported(): void
{
- /** @Given an AND group whose first child is an OR group */
- $group = Group::of(filters: [
- Group::of(filters: [
- Comparison::of(field: 'a', values: ['1'], operator: Operator::EQUAL),
- Comparison::of(field: 'b', values: ['2'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::OR),
- Comparison::of(field: 'c', values: ['3'], operator: Operator::EQUAL)
- ], operator: LogicalOperator::AND);
-
- /** @When rendering it as an RSQL expression */
- $expression = $group->toExpression()->value();
-
- /** @Then the nested OR group is wrapped in parentheses */
- self::assertSame('(a==1,b==2);c==3', $expression);
+ /** @Given a query whose parentheses nest an OR group inside an AND group */
+ $query = Query::from(parameters: ['filter' => '(a==1,b==2);c==3']);
+
+ /** @Then an exception carrying the raw filter query string is raised */
+ $this->expectException(FilterShapeNotSupported::class);
+ $this->expectExceptionMessage('Filter shape <(a==1,b==2);c==3> is not supported.');
+
+ /** @When building the criteria from the query */
+ Criteria::fromQuery(request: $query, schema: $this->schema);
}
- public function testFilteringWhenDoubleQuotedValueGivenThenComparisonStripsQuotes(): void
+ public function testFromQueryWhenFieldNotAllowedThenThrowsFilterFieldNotAllowed(): void
{
- /** @Given a query carrying a double-quoted value with whitespace */
- $query = Query::from(parameters: ['filter' => 'name=="John Doe"']);
+ /** @Given a schema allowing only the status field */
+ $schema = Schema::create()->filterable(field: 'status', operators: [Operator::EQUAL]);
- /** @When reading the filtering specification */
- $filter = Criteria::fromQuery(request: $query)->filtering();
+ /** @And a query filtering by a field that was never allowed */
+ $query = Query::from(parameters: ['filter' => 'discount==10']);
- /** @Then the comparison carries the value with the surrounding quotes stripped */
- self::assertEquals(Comparison::of(field: 'name', values: ['John Doe'], operator: Operator::EQUAL), $filter);
+ /** @Then an exception indicating the filter field is not allowed is raised */
+ $this->expectException(FilterFieldNotAllowed::class);
+ $this->expectExceptionMessage('Filter field ['"]?)(?P