Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
52d40c1
docs: add components audit remediation plan
binaryfire Aug 22, 2026
9c69065
docs(validation): add audit remediation plan
binaryfire Aug 22, 2026
29bd93e
fix(validation): return distinct presence values
binaryfire Aug 23, 2026
ac6062c
refactor(validation): make benchmarks deterministic
binaryfire Aug 23, 2026
d3b14d2
refactor(validation): preserve semantics in compiled execution
binaryfire Aug 23, 2026
34fbee1
test(validation): cover rule compilation boundaries
binaryfire Aug 23, 2026
2763a98
test(validation): cover ordered compiled execution
binaryfire Aug 23, 2026
abab078
test(validation): cover database-semantic presence facts
binaryfire Aug 23, 2026
6c50e9d
test(validation): cover ordered exclusion preflight
binaryfire Aug 23, 2026
6767aac
test(validation): run batching across database drivers
binaryfire Aug 23, 2026
3934d3c
docs(validation): clarify exact date formats
binaryfire Aug 23, 2026
9c830a6
docs: sharpen Laravel porting guide criteria
binaryfire Aug 23, 2026
6cb9538
docs(validation): finalize remediation plan
binaryfire Aug 23, 2026
53de6ef
docs(validation): remove master remediation ledger
binaryfire Aug 23, 2026
c720123
fix(validation): preserve non-scalar rule ordering
binaryfire Aug 23, 2026
1ca6902
fix(validation): keep presence facts binding-exact
binaryfire Aug 23, 2026
8610163
fix(validation): preserve ordered compiled execution
binaryfire Aug 23, 2026
7bc34f2
perf(validation): benchmark production planner wiring
binaryfire Aug 23, 2026
8896a91
docs(validation): finalize remediation design
binaryfire Aug 23, 2026
62335b1
fix(validation): avoid PHP 8.5 NAN coercion warnings
binaryfire Aug 23, 2026
7a90b13
perf(validation): reuse parsed rules in delegated fallback
binaryfire Aug 23, 2026
6be4bc7
docs(validation): record single-pass fallback compilation
binaryfire Aug 23, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ The Working rules and the Avoid overengineering rules apply to all work in this

- **Use one source of truth** — Put all user documentation in `src/docs/`. Package READMEs are intentionally minimal, not a second documentation surface, and must not duplicate user documentation.
- **Write user documentation in Laravel-docs prose** — Use the simple, direct, human-friendly style of first-party Laravel documentation. Prefer natural explanations and examples over implementation language; avoid internal jargon, stiff wording, and needless detail.
- **Keep the Laravel porting guide current and focused** — Whenever a framework change introduces, changes, or removes a public API, feature, configuration surface, or supported integration in a way that a Laravel application or package porter genuinely must account for, update `src/docs/porting-from-laravel.md` in the same change. Hard boot or runtime failures, silent semantic differences, and commonly used framework surfaces normally qualify. Internal implementation differences, performance work that preserves the public contract, incidental source drift, package-specific details, and narrow edge cases that do not change normal porting decisions do not. The guide is a high-signal starting context for humans and LLMs, not an exhaustive framework diff or dumping ground. Treat its context size as a design constraint: keep additions concise and action-oriented, link to the canonical feature documentation instead of duplicating its detail, and remove stale or duplicated guidance whenever editing the guide.
- **Keep the Laravel porting guide current and focused** — Whenever a framework change introduces, changes, or removes a public API, feature, behavior, configuration surface, or supported integration in a way that a Laravel application or package porter genuinely must account for, update `src/docs/porting-from-laravel.md` in the same change. Do not add entries for things like bug fixes, internal implementation differences, performance work that preserves the public contract, incidental source drift, or narrow edge cases unless they change what a porter needs to do. The guide is a high-signal starting context for humans and LLMs, not an exhaustive framework diff or dumping ground. Treat its context size as a design constraint: keep additions concise and action-oriented, link to the canonical feature documentation instead of duplicating its detail, and remove stale or duplicated guidance whenever editing the guide.

#### Package READMEs

Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -1796,7 +1796,7 @@ The field under validation must be equal to the given date. The dates will be pa
<a name="rule-date-format"></a>
#### date_format:_format_,...

The field under validation must match one of the given _formats_. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class.
The field under validation must match one of the given _formats_. Matching is exact, so padded tokens such as `m` reject unpadded values such as `1`. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class.

For convenience, date-based rules may be constructed using the fluent `date` rule builder:

Expand Down
1 change: 0 additions & 1 deletion src/testing/src/PHPUnit/AfterEachTestSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,6 @@ protected function flushFrameworkState(): void
\Hypervel\Testing\TestResponse::flushState();
\Hypervel\Testing\TestView::flushState();
\Hypervel\Translation\Translator::flushState();
\Hypervel\Validation\Console\BenchmarkValidationCommand::flushState();
\Hypervel\Validation\Rule::flushState();
\Hypervel\Validation\Rules\Date::flushState();
\Hypervel\Validation\Rules\Email::flushState();
Expand Down
16 changes: 3 additions & 13 deletions src/validation/src/AttributePlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,22 @@

namespace Hypervel\Validation;

use Hypervel\Validation\Enums\SizeMode;

/**
* Compiled validation plan for a single attribute.
*
* Contains pre-resolved flags and the check list (inline + delegated).
* Immutable after compilation — safe to cache worker-lifetime and share
* by reference across requests without cloning. Per-request state (like
* which attributes are excluded) lives on the Validator instance, not here.
* Plans are immutable after compilation and shared by reference across
* attributes, requests, and concurrent coroutines. Execution and optimizer
* state must remain on the Validator instance rather than being attached here.
*/
final class AttributePlan
{
public bool $required = false;

public bool $nullable = false;

public bool $bail = false;

public bool $sometimes = false;

/** Pre-resolved from sibling type rules. Null if ambiguous or no type rule present. */
public ?SizeMode $sizeMode = null;

/** Whether any check is an implicit rule (runs even when attribute is absent). */
public bool $hasImplicitRule = false;

/** @var list<DelegatedCheck|InlineCheck> */
public array $checks = [];
}
221 changes: 137 additions & 84 deletions src/validation/src/BatchDatabaseChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,155 +4,208 @@

namespace Hypervel\Validation;

use Stringable;

/**
* Execute batched database queries for wildcard exists/unique validation.
*
* This is a thin query layer — rule interpretation and metadata extraction
* happen on the Validator (using its own parseTable, getQueryColumn, etc.).
* This class only receives pre-built groups and runs the batch queries.
* Query wildcard database-presence candidates in groups.
*
* Builds a PrecomputedPresenceVerifier that can be set on the validator,
* keeping original rule objects intact for correct error message resolution.
* The validator owns rule interpretation and ordered candidate selection. This
* class turns each complete query shape into database-proven execution-local
* facts consumed by PrecomputedPresenceVerifier.
*/
final class BatchDatabaseChecker
{
private const int CHUNK_SIZE = 1000;

/**
* Build a PrecomputedPresenceVerifier from grouped rules.
* Build a precomputed verifier from grouped candidates.
*
* @param array<string, array{meta: array<string, mixed>, values: list<mixed>}> $groups
* @param array<string, true> $unsafeTableColumns table:column pairs that must not be
* precomputed because other rules use the
* same pair with a different query shape
* @param array<string, array{
* meta: array{
* connection: ?string,
* table: string,
* column: string,
* wheres: array<string, mixed>,
* ignore: null|int|string,
* idColumn: ?string
* },
* values: list<mixed>
* }> $groups
*/
public static function buildVerifier(array $groups, DatabasePresenceVerifier $presenceVerifier, array $unsafeTableColumns = []): ?PrecomputedPresenceVerifier
public static function buildVerifier(array $groups, DatabasePresenceVerifier $presenceVerifier): ?PrecomputedPresenceVerifier
{
$verifier = new PrecomputedPresenceVerifier($presenceVerifier);

self::registerLookups($verifier, $presenceVerifier, $groups, $unsafeTableColumns);
foreach ($groups as $lookupKey => $group) {
self::registerLookup($verifier, $presenceVerifier, $lookupKey, $group['meta'], $group['values']);
}

return $verifier->hasLookups() ? $verifier : null;
}

/**
* Batch-query and register lookups on a verifier for grouped rules.
*
* If multiple groups collapse to the same table:column (different query
* shapes for the same target), none are registered — they all fall back
* to the real verifier. The PrecomputedPresenceVerifier API only keys
* by table:column, so it cannot distinguish between different query shapes.
* Query and register database-proven facts for one query shape.
*
* @param array<string, true> $unsafeTableColumns table:column pairs blocked from precomputing
* @param array{
* connection: ?string,
* table: string,
* column: string,
* wheres: array<string, mixed>,
* ignore: null|int|string,
* idColumn: ?string
* } $meta
* @param list<mixed> $values
*/
private static function registerLookups(
private static function registerLookup(
PrecomputedPresenceVerifier $verifier,
DatabasePresenceVerifier $presenceVerifier,
array $groups,
array $unsafeTableColumns = [],
string $lookupKey,
array $meta,
array $values,
): void {
// Detect table:column collisions — multiple query shapes targeting the
// same table:column cannot be safely stored in the verifier.
$tableColumnCounts = [];
foreach ($groups as $group) {
$verifierKey = $group['meta']['table'] . ':' . $group['meta']['column'];
$tableColumnCounts[$verifierKey] = ($tableColumnCounts[$verifierKey] ?? 0) + 1;
$representativeValues = self::normalizeCandidates($values);

if ($representativeValues === []) {
return;
}

foreach ($groups as $group) {
$values = self::uniqueStringValues($group['values']);
$stageOneSingleChunk = count($representativeValues) <= self::CHUNK_SIZE;
$stageOneValues = self::queryValues(
$presenceVerifier,
$meta,
array_values($representativeValues),
);
$comparisonIndex = [];

if ($values === null) {
continue;
}
foreach (array_keys($representativeValues) as $bindingKey) {
$comparisonIndex[substr($bindingKey, 1)][] = $bindingKey;
}

if ($values === []) {
continue;
}
$exactHits = [];
$knownPresent = [];
$provenAbsent = [];

$meta = $group['meta'];
$verifierKey = $meta['table'] . ':' . $meta['column'];
foreach ($stageOneValues as $value) {
$normalizedValue = PrecomputedPresenceVerifier::normalizeValue($value);

// Skip if multiple batch groups target the same table:column,
// or if non-batched rules also use this table:column pair.
if ($tableColumnCounts[$verifierKey] > 1 || isset($unsafeTableColumns[$verifierKey])) {
if ($normalizedValue === null) {
continue;
}

$fetched = self::queryValues(
$presenceVerifier,
$meta['connection'],
$meta['table'],
$meta['column'],
$values,
$meta['wheres'],
$meta['type'] === 'unique' ? $meta['ignore'] : null,
$meta['type'] === 'unique' ? $meta['idColumn'] : 'id',
);
// Query success proves each retained PDO binding was accepted. A returned
// equal string form is therefore exact for every matching submitted binding.
foreach ($comparisonIndex[$normalizedValue] ?? [] as $bindingKey) {
$exactHits[$bindingKey] = true;
}
}

$misses = array_diff_key($representativeValues, $exactHits);

// An isolation query is useful only after exact hits shrink the original candidate set.
if ($stageOneValues === []) {
$provenAbsent = array_fill_keys(array_keys($representativeValues), true);
} elseif (count($representativeValues) === 1 && $exactHits === []) {
$knownPresent[array_key_first($representativeValues)] = true;
} elseif ($exactHits !== [] && $misses !== []) {
$stageTwoValues = self::queryValues($presenceVerifier, $meta, array_values($misses));

if ($stageTwoValues === []) {
$provenAbsent = array_fill_keys(array_keys($misses), true);
} else {
foreach ($stageTwoValues as $value) {
$normalizedValue = PrecomputedPresenceVerifier::normalizeValue($value);

if ($normalizedValue === null) {
continue;
}

foreach ($comparisonIndex[$normalizedValue] ?? [] as $bindingKey) {
if (isset($misses[$bindingKey])) {
$knownPresent[$bindingKey] = true;
}
}
}

$verifier->addLookup($meta['table'], $meta['column'], $fetched);
if (count($misses) === 1) {
$knownPresent[array_key_first($misses)] = true;
}
}
}

$verifier->addLookup(
$lookupKey,
$exactHits,
$knownPresent,
$provenAbsent,
$stageOneSingleChunk,
);
}

/**
* Run the batched whereIn query and return matching values.
* Run chunked queries for one database query shape.
*
* Replays scalar where conditions matching DatabasePresenceVerifier::addWhere()
* behavior. Uses write PDO to match the presence verifier's behavior.
*
* @param array<int, mixed> $values
* @param array<string, mixed> $wheres Key => value pairs (column => value)
* @return array<int, mixed>
* @param array{
* connection: ?string,
* table: string,
* column: string,
* wheres: array<string, mixed>,
* ignore: null|int|string,
* idColumn: ?string
* } $meta
* @param list<float|int|string> $values
* @return list<mixed>
*/
private static function queryValues(
DatabasePresenceVerifier $presenceVerifier,
?string $connection,
string $table,
string $column,
array $meta,
array $values,
array $wheres,
mixed $ignore = null,
string $idColumn = 'id',
): array {
$results = [];

foreach (array_chunk($values, self::CHUNK_SIZE) as $chunk) {
array_push($results, ...$presenceVerifier->getExistingValues(
$table,
$column,
$meta['table'],
$meta['column'],
$chunk,
$connection,
$ignore,
$idColumn,
$wheres,
$meta['connection'],
$meta['ignore'],
$meta['idColumn'],
$meta['wheres'],
));
}

return $results;
}

/**
* Deduplicate and cast values to strings for batch queries.
* Normalize candidates while retaining the first raw SQL binding.
*
* An unsupported array item rejects only that concrete array candidate;
* safe siblings in the same query-shape group remain batchable.
*
* @param array<mixed> $values
* @return null|list<string>
* @param list<mixed> $values
* @return array<string, float|int|string>
*/
private static function uniqueStringValues(array $values): ?array
private static function normalizeCandidates(array $values): array
{
$normalized = [];
$representativeValues = [];

foreach ($values as $value) {
$candidateValues = [];

foreach (is_array($value) ? $value : [$value] as $item) {
if (! is_scalar($item) && ! $item instanceof Stringable) {
return null;
$bindingKey = PrecomputedPresenceVerifier::bindingKey($item);

if ($bindingKey === null) {
continue 2;
}

$normalized[] = (string) $item;
$candidateValues[$bindingKey] ??= $item;
}

foreach ($candidateValues as $bindingKey => $rawValue) {
$representativeValues[$bindingKey] ??= $rawValue;
}
}

return array_values(array_unique($normalized, SORT_STRING));
return $representativeValues;
}
}
Loading