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
205 changes: 205 additions & 0 deletions example/app/Services/ValidationStressService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

namespace Example\App\Services;

use Framework\Validation\Rule;
use Framework\Validation\Validator;

/**
* Builds a large product payload and its rule set, to exercise the validator
* with thousands of wildcard-expanded keys.
*/
class ValidationStressService
{
public const ATTRIBUTES_PER_VARIANT = 3;

public function rules(): array
{
return [
// Product level.
'product' => 'required|array',
'product.title' => 'required|string|min:3|max:120',
'product.slug' => 'required|string|alpha_dash|max:120',
'product.status' => 'required|in:draft,published,archived',
'product.type' => 'required|in:simple,variable',
'product.description' => 'nullable|string|max:5000',
'product.vendor_email' => 'required|string|email',
'product.website' => 'nullable|string|url',
'product.published_at' => 'nullable|date',
'product.featured' => 'required|boolean',
'product.tax_rate' => 'required|numeric|min:0|max:100',
'product.tags' => 'nullable|array|max:20',
'product.tags.*' => 'required|string|max:30',

// Variants.
'variants' => 'required|array|min:1',
'variants.*.sku' => 'required|string|alpha_dash|max:50',
'variants.*.name' => 'required|string|min:2|max:100',
'variants.*.price' => 'required|numeric|min:0',
'variants.*.sale_price' => 'nullable|numeric|min:0',
'variants.*.cost' => 'nullable|numeric|min:0',
'variants.*.stock' => 'required|integer|min:0',
'variants.*.low_stock_threshold' => 'nullable|integer|min:0',
'variants.*.weight' => 'nullable|numeric|min:0',
'variants.*.length' => 'nullable|numeric|min:0',
'variants.*.width' => 'nullable|numeric|min:0',
'variants.*.height' => 'nullable|numeric|min:0',
'variants.*.color' => 'required|string|max:30',
'variants.*.size' => 'required|in:xs,s,m,l,xl,xxl',
'variants.*.material' => 'nullable|string|max:50',
'variants.*.barcode' => 'nullable|string|alpha_num|max:20',
'variants.*.is_active' => 'required|boolean',
'variants.*.is_default' => 'required|boolean',
'variants.*.backorder' => 'required|in:no,notify,yes',
'variants.*.position' => 'required|integer|min:0',
'variants.*.image_url' => 'nullable|string|url',
'variants.*.available_from' => 'nullable|date',
'variants.*.shipping_class' => 'nullable|string|max:50',
'variants.*.tax_class' => 'nullable|string|max:50',
'variants.*.note' => 'nullable|string|max:255',
'variants.*.digital' => [
Rule::when(
fn ($data) => ($data['product']['type'] ?? null) === 'variable',
'required|boolean',
'nullable|boolean'
),
],
'variants.*.discount_percent' => [
'nullable',
function ($value, $key) {
return $value === null || ($value >= 0 && $value <= 90)
? true
: "The {$key} must be between 0 and 90.";
},
],

// Nested wildcard: every variant carries a list of attributes.
'variants.*.attributes' => 'required|array|min:1',
'variants.*.attributes.*.name' => 'required|string|max:50',
'variants.*.attributes.*.value' => 'required|string|max:100',
];
}

public function payload(int $variants, bool $invalid = false): array
{
$sizes = ['xs', 's', 'm', 'l', 'xl', 'xxl'];
$items = [];

for ($i = 0; $i < $variants; $i++) {
$attributes = [];

for ($a = 0; $a < static::ATTRIBUTES_PER_VARIANT; $a++) {
$attributes[] = ['name' => "attribute-{$a}", 'value' => "value-{$i}-{$a}"];
}

$items[] = [
'sku' => "SKU-{$i}",
'name' => "Variant {$i}",
'price' => 19.99 + $i,
'sale_price' => 17.99 + $i,
'cost' => 9.5,
'stock' => 100 + $i,
'low_stock_threshold' => 5,
'weight' => 0.4,
'length' => 30,
'width' => 20,
'height' => 2,
'color' => 'black',
'size' => $sizes[$i % count($sizes)],
'material' => 'cotton',
'barcode' => "BC{$i}",
'is_active' => true,
'is_default' => $i === 0,
'backorder' => 'notify',
'position' => $i,
'image_url' => "https://example.test/images/{$i}.jpg",
'available_from' => '2026-11-13',
'shipping_class' => 'standard',
'tax_class' => 'reduced',
'note' => null,
'digital' => false,
'discount_percent' => 10,
'attributes' => $attributes,
];
}

// One broken variant in every ten, each breaking a different kind of rule.
if ($invalid) {
foreach ($items as $i => &$item) {
if ($i % 10 !== 0) {
continue;
}

$item['sku'] = 'bad sku!';
$item['price'] = -5;
$item['stock'] = 'many';
$item['size'] = 'huge';
$item['image_url'] = 'not-a-url';
$item['discount_percent'] = 95;
$item['attributes'][0]['value'] = '';
}
unset($item);
}

return [
'product' => [
'title' => 'Stress Test Tee',
'slug' => 'stress-test-tee',
'status' => 'published',
'type' => 'variable',
'description' => 'A product with a very large number of variants.',
'vendor_email' => 'vendor@example.test',
'website' => 'https://example.test',
'published_at' => '2026-11-13',
'featured' => true,
'tax_rate' => 7.5,
'tags' => ['apparel', 'cotton', 'stress-test'],
],
'variants' => $items,
];
}

/**
* Validate the payload and report timings, key counts and errors.
*/
public function run(int $variants, bool $invalid = false): array
{
$rules = $this->rules();
$data = $this->payload($variants, $invalid);

$started = microtime(true);
$validator = Validator::make($data, $rules);
$parsed = microtime(true);
$passes = $validator->passes();
$checked = microtime(true);
$validated = $passes ? $validator->validated() : null;
$finished = microtime(true);

$errors = $validator->errors();
$errors_by_field = [];

foreach (array_keys($errors) as $key) {
$field = preg_replace('/\.\d+(?=\.|$)/', '.*', $key);
$errors_by_field[$field] = ($errors_by_field[$field] ?? 0) + 1;
}

return [
'variants' => $variants,
'invalid' => $invalid,
'rule_entries' => count($rules),
'expanded_keys' => count($validator->get_rules()),
'passes' => $passes,
'error_keys' => count($errors),
'errors_by_field' => $errors_by_field,
'sample_errors' => array_slice($errors, 0, 10, true),
'validated_variants' => $validated === null ? null : count($validated['variants'] ?? []),
'timings_ms' => [
'parse' => round(($parsed - $started) * 1000, 2),
'passes' => round(($checked - $parsed) * 1000, 2),
'validated' => round(($finished - $checked) * 1000, 2),
'total' => round(($finished - $started) * 1000, 2),
],
'peak_memory_mb' => round(memory_get_peak_usage(true) / 1048576, 1),
];
}
}
19 changes: 18 additions & 1 deletion example/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Example\App\Models\Blog;
use Example\App\Models\Event;
use Example\App\Resources\TestResource;
use Example\App\Services\ValidationStressService;
use Framework\Http\Request;
use Framework\Middlewares\AdminMiddleware;
use Framework\Middlewares\AuthMiddleware;
Expand Down Expand Up @@ -93,4 +94,20 @@
})->throttle(5, 1);


Route::get('/new-route', [TestController::class, 'get']);
Route::get('/new-route', [TestController::class, 'get']);

/*
* Validator stress test. Public, no middleware.
*
* Validates a product with many variants against a wide rule set that uses
* wildcard and nested wildcard keys, and reports timings and errors.
*
* GET /wp-json/framework/v1/validation-stress?variants=180
* GET /wp-json/framework/v1/validation-stress?variants=500&invalid=1
*/
Route::get('/validation-stress', function (Request $request, ValidationStressService $stress) {
$variants = max(1, min(2000, (int) $request->get('variants', 180, 'int')));
$invalid = (bool) $request->get('invalid', false, 'bool');

return response()->json($stress->run($variants, $invalid));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-13
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
## Context

See proposal.md (Why) for the measurements.

How messages flow today:

- `ValidationRule::__construct()` calls `process_default_messages($this->constraints)`. For
`default` and each supported constraint, it takes the custom message if there is one
(`get_custom_message()`, which reads `$this->custom_messages` at `"$name.$key"`), otherwise
`message('validator.<rule>.<key>')`. The result goes into `$this->default_messages`, which is
a plain `protected array`. About 21 call sites in `Rules/` read `$this->default_messages[$key]`
directly, and a consumer's own rule subclass may do the same.
- `Validator::apply()` → `sync_custom_messages($this->custom_messages)` →
`sync_default_messages_with_custom_messages()` → `process_default_messages()` again, for
every rule on every key. This has to stay per key **when there are custom messages**, because
`get_custom_message()` uses `$this->name`, and a closure message also gets `$this->value` and
`$this->data`.
- `message()` → `app()->make(MessagesBag::class)->get()` → `Arr::get($this->messages(), …)`.
`messages()` rebuilds `defaults()` (a large array literal, not cached while opcache is off) and
merges in `config('messages')`. `config()` already caches each file in a function-static
`$cache` for the request.

## Goals / Non-Goals

**Goals:**
- No message-table lookups while checking keys when the validator has no custom messages.
- Cheaper lookups everywhere else.
- Byte-for-byte the same messages as today in every case (spec: validation-messages).

**Non-Goals:**
- Lazy default messages. They would need `$default_messages` to become an accessor or
`ArrayAccess` object, which breaks subclasses that read the array. Not needed for the
numbers above.
- Per-field indexing of custom messages to skip unrelated fields.
- Changes to `Validator`, `RuleParser`, `RuleFactory` or the rule classes.

## Decisions

### D1. `MessagesBag::get()` reads `$this->messages`

Replace `Arr::get($this->messages(), $key, '')` with `Arr::get($this->messages, $key, '')`. The
constructor already fills `$this->messages`, and the bag is a container singleton. Its only
dynamic input, `config('messages')`, is cached per request by `config()`, so a lookup cannot
return a different message than it does today within one request. In tests, `TestCase` resets the
container, so each test gets a fresh bag.

*Alternative:* keep `messages()` but memoise it. Rejected: that duplicates what the constructor
already does.

### D2. `sync_custom_messages()` returns early when no custom messages are involved

```php
if ($messages === [] && $this->custom_messages === []) {
return $this;
}
```

Why this is exact:
- With `$this->custom_messages === []`, every `get_custom_message()` returns `null` whatever
`$this->name` or `$this->value` is, so `process_default_messages()` would produce only the
`message()` defaults.
- Those defaults are what `$default_messages` already holds. The constructor computed them with
empty custom messages, and clones copy them (`validator-parse-once`).
- The `$this->custom_messages === []` half covers a rule object that carried custom messages from
an earlier validator. It recomputes once and drops back to defaults (spec: shared rule object).

The guard goes in `sync_custom_messages()`, not `sync_default_messages_with_custom_messages()`,
so a subclass that overrides the latter still behaves the same whenever custom messages exist.

*Alternatives:*
- **Lazy `$default_messages`.** Rejected (see Non-Goals).
- **Per-class static cache of defaults.** Rejected: it would survive container resets in tests
and long-running processes, and D2 already removes the per-key lookups.

### D3. Counting test double for "no lookups"

`tests/Support/Validation/CountingMessagesBag.php` extends `MessagesBag`, counts `get()` calls, and
is bound as the `MessagesBag` singleton inside the test. The "no lookups" scenario asserts the
count stays 0 during `passes()` on a passing payload. Custom-message tests are written first and
must pass on today's code. They lock in current behaviour, because none exist yet.

## Risks / Trade-offs

- [A consumer rule subclass overrides `get_default_message()` or `process_default_messages()` to
depend on `$this->name`, `$this->value` or `$this->data`] → With no custom messages, its
defaults would no longer be recomputed per key. Mitigation: document this in the
`sync_custom_messages()` docblock. Such a rule can override `sync_custom_messages()`. No rule
in `src/` does this.
- [Code changes `config('messages')` partway through a request] → Not possible today because
`config()` caches. Unchanged by D1.

## Migration Plan

No signature changes; ships with the next release. Rollback is a revert of two lines and a guard.

## Benchmark

`XDEBUG_MODE=off composer test:unit -- openspec/changes/validator-parse-once/bench/ValidatorBenchTest.php`
plus the stress payload from `example/app/Services/ValidationStressService.php` (180 variants).

| `passes()` (stress payload) | Before | After |
|---|---|---|
| CLI, Xdebug off | 187 ms | 12 ms |
| CLI, Xdebug on | 460 ms | 28 ms |
| Web, playground | 1,238 ms | _pending: re-run `GET /validation-stress?variants=180`_ |
Loading
Loading