From 9d9584fdfb39523a7f986e3a0bb5869a967b2db6 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 21 Jun 2026 09:50:11 +0300 Subject: [PATCH] Add testo/bench benchmarks and document performance characteristics - Add testo/bench ^0.1.5 to require-dev, testo.php config, benchmarks/ dir - SpecificationBuilderBench: builder chain vs direct CompositeSpecification - OrCompositionBench: orWhere() vs direct OrSpecification::create() - Document performance trade-offs in README and llms.txt --- .php-cs-fixer.php | 1 + Makefile | 5 ++- README.md | 31 +++++++++++++++ benchmarks/OrCompositionBench.php | 50 ++++++++++++++++++++++++ benchmarks/SpecificationBuilderBench.php | 48 +++++++++++++++++++++++ composer.json | 3 ++ llms.txt | 7 ++++ testo.php | 18 +++++++++ 8 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 benchmarks/OrCompositionBench.php create mode 100644 benchmarks/SpecificationBuilderBench.php create mode 100644 testo.php diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 3696757..42388a7 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -7,6 +7,7 @@ $finder = (new Finder()) ->in([ + __DIR__ . '/benchmarks', __DIR__ . '/src', __DIR__ . '/tests', ]); diff --git a/Makefile b/Makefile index c6c246b..4e1eac2 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ DOCKER := docker run --rm -v "$(PWD)":/app -w /app composer:2 DOCKER_HOST := docker run --rm --network host -v "$(PWD)":/app -w /app PCOV_BOOTSTRAP := apk add --no-cache $$PHPIZE_DEPS >/dev/null && pecl install pcov >/dev/null && docker-php-ext-enable pcov -.PHONY: build cs cs-fix psalm test mutation rector rector-fix install normalize require-checker \ +.PHONY: build bench cs cs-fix psalm test mutation rector rector-fix install normalize require-checker \ test-coverage test-coverage-ci update-deps release-check bc-check audit-package install: @@ -11,6 +11,9 @@ install: build: $(DOCKER) composer build +bench: + $(DOCKER) composer bench + cs: $(DOCKER) composer cs diff --git a/README.md b/README.md index 6f116fa..9d64ef1 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,37 @@ composer install && php examples/builder.php escaped — never build it from untrusted input. Pass user values only through the `$params` map (placeholders): `new RawSpecification('age > :age', ['age' => $value])`. +## Performance + +`SpecificationBuilder` is immutable — each `where*()`, `limit()`, and `offset()` call +clones the builder before returning. This is safe and predictable but carries a small +overhead (~3.3µs for a 7-step chain, vs ~2.4µs for direct `CompositeSpecification` +composition). `orWhere()` additionally allocates a temporary builder and invokes a +closure (~2.8µs vs ~1.4µs for direct `OrSpecification::create()`). + +For most web request workloads (1–5 specs per request, DB queries taking 1–100ms) +this overhead is negligible. For **high-throughput batch processing** where specs are +built in a tight loop, prefer the direct `CompositeSpecification` API: + +```php +// ~26% faster than SpecificationBuilder for a 7-condition chain +$spec = CompositeSpecification::create() + ->withComparison('status', 'active') + ->withComparison('age', 18, '>') + ->withComparison('role', ['admin', 'editor'], 'in') + ->withLimit(100); + +// ~48% faster than orWhere() for OR composition +$spec = CompositeSpecification::create() + ->withSpecification(OrSpecification::create( + CompositeSpecification::create()->withComparison('status', 'active'), + CompositeSpecification::create()->withComparison('status', 'pending'), + )); +``` + +Benchmarks live in `benchmarks/` and run via `composer bench` (requires +[testo/bench](https://github.com/php-testo/testo)). + ## Notes - `ilike` / `not ilike` are PostgreSQL-specific; other drivers (e.g. MySQL) do not diff --git a/benchmarks/OrCompositionBench.php b/benchmarks/OrCompositionBench.php new file mode 100644 index 0000000..74f401b --- /dev/null +++ b/benchmarks/OrCompositionBench.php @@ -0,0 +1,50 @@ + [self::class, 'buildDirect'], + ], + calls: 1_000, + iterations: 10, + )] + public static function buildViaOrWhere(): CompositeSpecification + { + return SpecificationBuilder::create() + ->whereEqual('status', 'active') + ->orWhere(fn($b) => $b->whereEqual('status', 'pending')) + ->orWhere(fn($b) => $b->whereEqual('status', 'trial')) + ->build(); + } + + public static function buildDirect(): CompositeSpecification + { + return CompositeSpecification::create() + ->withSpecification(specification: OrSpecification::create( + CompositeSpecification::create() + ->withComparison(column: 'status', value: 'active', operator: '='), + CompositeSpecification::create() + ->withComparison(column: 'status', value: 'pending', operator: '='), + CompositeSpecification::create() + ->withComparison(column: 'status', value: 'trial', operator: '='), + )); + } +} diff --git a/benchmarks/SpecificationBuilderBench.php b/benchmarks/SpecificationBuilderBench.php new file mode 100644 index 0000000..a0f938d --- /dev/null +++ b/benchmarks/SpecificationBuilderBench.php @@ -0,0 +1,48 @@ + [self::class, 'buildDirect'], + ], + calls: 1_000, + iterations: 10, + )] + public static function buildViaBuilder(): CompositeSpecification + { + return SpecificationBuilder::create() + ->whereEqual('status', 'active') + ->whereGreaterThan('age', 18) + ->whereIn('role', ['admin', 'editor', 'viewer']) + ->whereLike('email', '%@example.com') + ->whereNotNull('verified_at') + ->limit(100) + ->offset(0) + ->build(); + } + + public static function buildDirect(): CompositeSpecification + { + return CompositeSpecification::create() + ->withComparison(column: 'status', value: 'active', operator: '=') + ->withComparison(column: 'age', value: 18, operator: '>') + ->withComparison(column: 'role', value: ['admin', 'editor', 'viewer'], operator: 'in') + ->withComparison(column: 'email', value: '%@example.com', operator: 'like') + ->withComparison(column: 'verified_at', value: null, operator: 'is not') + ->withLimit(limit: 100) + ->withOffset(offset: 0); + } +} diff --git a/composer.json b/composer.json index a45e28b..7e29982 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "phpunit/phpunit": "^11.5", "rector/rector": "^2.4", "roave/backward-compatibility-check": "^8.0", + "testo/bench": "^0.1.5", "vimeo/psalm": "^6.16", "yiisoft/cache": "^3.0", "yiisoft/db-sqlite": "^2.0", @@ -52,6 +53,7 @@ }, "autoload-dev": { "psr-4": { + "Rasuvaeff\\Specification\\Benchmarks\\": "benchmarks/", "Rasuvaeff\\Specification\\Tests\\": "tests/" } }, @@ -68,6 +70,7 @@ }, "scripts": { "bc-check": "roave-backward-compatibility-check", + "bench": "testo -vv", "build": [ "@composer validate --strict", "@composer normalize --dry-run", diff --git a/llms.txt b/llms.txt index a9698df..a8e1795 100644 --- a/llms.txt +++ b/llms.txt @@ -106,6 +106,13 @@ between not between is is not`. Validation: NULL only with `= != <> is is not`; `in/not in/between/not between` require array (`between` exactly two); LIKE ops require string. `DateTimeInterface` values are normalized to `Y-m-d H:i:s`. +## Performance + +`SpecificationBuilder` clones itself on every step (immutable). Overhead: ~3.3µs +for a 7-step chain vs ~2.4µs direct; `orWhere()` ~2.8µs vs ~1.4µs direct. +Negligible for web requests (DB query >> builder cost). For hot batch loops, +use `CompositeSpecification` / `OrSpecification::create()` directly. + ## Custom traversal Implement `SpecificationVisitor` (visitComparison/visitComposite/visitNot/ diff --git a/testo.php b/testo.php new file mode 100644 index 0000000..0a2aafd --- /dev/null +++ b/testo.php @@ -0,0 +1,18 @@ +