Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

$finder = (new Finder())
->in([
__DIR__ . '/benchmarks',
__DIR__ . '/src',
__DIR__ . '/tests',
]);
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -11,6 +11,9 @@ install:
build:
$(DOCKER) composer build

bench:
$(DOCKER) composer bench

cs:
$(DOCKER) composer cs

Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions benchmarks/OrCompositionBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\Specification\Benchmarks;

use Rasuvaeff\Specification\CompositeSpecification;
use Rasuvaeff\Specification\OrSpecification;
use Rasuvaeff\Specification\SpecificationBuilder;
use Testo\Bench;

/**
* Compares orWhere() (callback-based builder API) against
* manually constructing the equivalent OrSpecification tree.
*
* orWhere() allocates a temporary mutable builder per call and invokes
* a closure; direct construction avoids both. Both produce the same
* CompositeSpecification([OrSpecification([...])]) structure.
*/
final class OrCompositionBench
{
#[Bench(
callables: [
'direct' => [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: '='),
));
}
}
48 changes: 48 additions & 0 deletions benchmarks/SpecificationBuilderBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\Specification\Benchmarks;

use Rasuvaeff\Specification\CompositeSpecification;
use Rasuvaeff\Specification\SpecificationBuilder;
use Testo\Bench;

/**
* Compares immutable SpecificationBuilder (clones on every step)
* against direct CompositeSpecification composition (no builder overhead).
*/
final class SpecificationBuilderBench
{
#[Bench(
callables: [
'direct' => [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);
}
}
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -52,6 +53,7 @@
},
"autoload-dev": {
"psr-4": {
"Rasuvaeff\\Specification\\Benchmarks\\": "benchmarks/",
"Rasuvaeff\\Specification\\Tests\\": "tests/"
}
},
Expand All @@ -68,6 +70,7 @@
},
"scripts": {
"bc-check": "roave-backward-compatibility-check",
"bench": "testo -vv",
"build": [
"@composer validate --strict",
"@composer normalize --dry-run",
Expand Down
7 changes: 7 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` (visitComparison/visitComposite/visitNot/
Expand Down
18 changes: 18 additions & 0 deletions testo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

use Testo\Application\Config\ApplicationConfig;
use Testo\Application\Config\FinderConfig;
use Testo\Application\Config\SuiteConfig;
use Testo\Bench\BenchmarkPlugin;

return new ApplicationConfig(
suites: [
new SuiteConfig(
name: 'Benchmarks',
location: new FinderConfig(include: ['benchmarks']),
plugins: [new BenchmarkPlugin()],
),
],
);
Loading