pimbay-php/search-query

Framework-agnostic, strict search/pagination contracts (page, slice, cursor) with no runtime magic — a foundation for datasource-specific extensions (Doctrine, Eloquent, Elasticsearch, ...).

Maintainers

Package info

codeberg.org/pimbay-php/search-query

Homepage

Issues

Documentation

pkg:composer/pimbay-php/search-query

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

dev-main 2026-08-12 03:39 UTC

This package is auto-updated.

Last update: 2026-08-12 02:00:08 UTC


README

Latest Version on Packagist PHP Version License Code Coverage Mutation Score

Framework-agnostic, strict search/pagination contracts. No runtime magic — every contract is an explicit type, no duck-typed queries, no string-driven filter configuration. The library defines three independent pagination families — Page, Slice, Cursor — plus orthogonal capability adapters (count, ids, head, all), and nothing else: no query builder, no filter/sort DSL, no datasource code.

Why three pagination families, not one adapter interface

A single "does everything" adapter interface forces every implementation to either support a capability it genuinely can't do correctly, or throw at runtime. Each family here exists because it has a distinct, real capability profile:

FamilyAdapterResultUse it when
PagePageAdapterPageResultadmin-style listing with page numbers — you need totalCount/pageCount
SliceSliceAdapterSliceResultinfinite scroll — hasNextPage without a separate, expensive count query
CursorCursorAdapterCursorResultvery large/unbounded datasets, Elasticsearch search_after, keyset pagination

None of the three requires the other two. A concrete adapter implements exactly the combination a given datasource can do cheaply and correctly — nothing is forced on it. Page additionally extends Slice's contract (PageResult extends SliceResult), since everything knowable from a Slice result is also true of a Page result.

Separately, Adapter\CountableAdapter, Adapter\IdentifiableAdapter, Adapter\HeadableAdapter, and Adapter\AllAdapter are orthogonal capability adapters, independent of all three families and of each other. A repository calls them directly, not through a pagination orchestrator.

Installation

composer require pimbay-php/search-query

Usage

Page\PageAssembler

Page-numbered pagination with a known total count. Requires a PageAdapter — this example uses the bundled Adapter\InMemoryArrayAdapter, so it runs with no external datasource.

<?php

declare(strict_types=1);

use PimBay\SearchQuery\Adapter\InMemoryArrayAdapter;
use PimBay\SearchQuery\Page\PageAssembler;

$adapter = new InMemoryArrayAdapter(['apple', 'banana', 'cherry', 'date', 'elderberry']);

$result = (new PageAssembler())->paginate($adapter, 2, 2);

$result->getData();          // ['cherry', 'date']
$result->getTotalCount();    // 5
$result->getPageCount();     // 3
$result->hasNextPage();      // true
$result->isOutOfRange();     // false — request page 10 instead to see this flip to true

PageAssembler never silently clamps to the last valid page — the caller decides what to do with an out-of-range request (e.g. a 404 for an SEO-sensitive HTML listing, or a plain empty result for a JSON API).

Slice\SliceAssembler

Offset-addressed, but without a total count — cheaper than Page when you only need "is there more?", not "how many pages total?".

<?php

declare(strict_types=1);

use PimBay\SearchQuery\Adapter\InMemoryArrayAdapter;
use PimBay\SearchQuery\Slice\SliceAssembler;

$adapter = new InMemoryArrayAdapter(['apple', 'banana', 'cherry', 'date', 'elderberry']);

$result = (new SliceAssembler())->paginate($adapter, 1, 2);

$result->getData();       // ['apple', 'banana']
$result->hasNextPage();   // true
$result->isOutOfRange();  // false — request page 10 instead to see this flip to true

isOutOfRange() is true when getCurrentPage() > 1 and the adapter returned no results — with no total count available, this is the only signal Slice has for "past the end", but for an offset-addressed source it's unambiguous.

Cursor\CursorAssembler

Forward-only, keyset-style pagination. No page numbers — only an opaque cursor string and a nextCursor to continue from.

<?php

declare(strict_types=1);

use PimBay\SearchQuery\Adapter\InMemoryArrayAdapter;
use PimBay\SearchQuery\Cursor\CursorAssembler;

$adapter = new InMemoryArrayAdapter(['apple', 'banana', 'cherry', 'date', 'elderberry']);

$firstPage = (new CursorAssembler())->paginate($adapter, cursor: null, size: 2);

$firstPage->getData();       // ['apple', 'banana']
$firstPage->hasNextPage();   // true

$secondPage = (new CursorAssembler())->paginate($adapter, $firstPage->getNextCursor(), 2);

$secondPage->getData();      // ['cherry', 'date']

SearchTerms\SearchTermsParser

Splits raw search-term strings into equals/notEquals/likes/notLikes buckets, based on a leading negation marker and an embedded wildcard marker. Pure string parsing — no SQL, no column mapping; a datasource-specific package (e.g. search-query-doctrine) turns the result into actual query conditions against a named column.

<?php

declare(strict_types=1);

use PimBay\SearchQuery\SearchTerms\SearchTermsConfig;
use PimBay\SearchQuery\SearchTerms\SearchTermsParser;

$parsed = (new SearchTermsParser())->parseString('dog hors* -cow', new SearchTermsConfig());

$parsed->equals;    // ['dog']
$parsed->likes;     // ['hors*']
$parsed->notEquals; // ['cow']

SearchTermsConfig is tunable (anywhere, minLength, likeChar, ignoreChar) and validates its own markers (non-empty, mutually distinct) via SearchQueryException. Negated terms are grouped as AND — -dog -cat excludes any record mentioning either, not only records mentioning both (see docs/DECISIONS.md for the reasoning).

Adapter\InMemoryArrayAdapter

A reference PageAdapter/SliceAdapter/CursorAdapter implementation over a plain PHP array — used in the examples above, and useful in your own unit tests for exercising PageAssembler/SliceAssembler/CursorAssembler without a real datasource. Not a template for production datasource adapters; those belong in a search-query-<datasource> package.

<?php

declare(strict_types=1);

use PimBay\SearchQuery\Adapter\InMemoryArrayAdapter;

final class Person
{
    public function __construct(public readonly int $id, public readonly string $name) {}
}

$people = [new Person(1, 'Alice'), new Person(2, 'Bob')];

$adapter = new InMemoryArrayAdapter($people, extractId: fn (Person $p) => $p->id);

$adapter->count();  // 2
$adapter->ids();    // [1, 2]

Full usage reference (a repository shaped for a real datasource): docs/usage.md.

Exceptions

Every failure mode this library can raise is a named constructor on the single Exception\SearchQueryException:

use PimBay\SearchQuery\Exception\SearchQueryException;

try {
    (new PageAssembler())->paginate($adapter, 0, 10);
} catch (SearchQueryException $e) {
    // $e->getMessage() === 'Page index must be at least one, 0 given.'
}
Named constructorRaised by
SearchQueryException::invalidPageIndex()PageAssembler/SliceAssembler::paginate()$page less than 1
SearchQueryException::invalidSize()PageAssembler/SliceAssembler/CursorAssembler::paginate()$size less than 1
SearchQueryException::emptyCursor()CursorAssembler::paginate()$cursor (or an adapter's returned nextCursor) is an empty string (use null, not '', for "no cursor")

Testing

composer test:83        # docker compose run php83 — phpunit, no coverage
composer test:84        # docker compose run php84 — phpunit, no coverage
composer test:85        # docker compose run php85 — phpunit, no coverage
composer test:all       # test:83 + test:84 + test:85
composer test:coverage  # docker compose run php83 — phpunit --coverage-text
composer test:mutation # infection — mutation testing, --min-msi=100 --min-covered-msi=100
PHPStatus
8.3
8.4
8.5

Development Helpers

composer php:cs        # php-cs-fixer, --dry-run --diff (check only)
composer php:cs:fix    # same, applies the fix
composer php:stan      # phpstan analyse, level: max

Packages in the stack

PackageDescription
pimbay-php/search-queryThis package — framework-agnostic contracts, no datasource code.
pimbay-php/search-query-doctrine(planned) PageAdapter/SliceAdapter/CountableAdapter/IdentifiableAdapter/HeadableAdapter/AllAdapter over Doctrine DBAL/ORM.
pimbay-php/search-query-pimcore(planned) Terminal implementation over Pimcore DataObject\Listing.

Architecture & Decisions

License

Public domain — Unlicense

Created by Jan Sarmir · No conditions · No copyright

Bundled third-party dependencies and their licenses: docs/THIRD-PARTY-NOTICES.md.