From 23debff5acd74eb9899bce661cd0894058f3b3a8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:14:01 +0000 Subject: [PATCH 01/23] Fix relationship expression counts and nested morph queries Port Laravel framework #57830 from 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Accept the expression contract at all relationship count, column and aggregate boundaries, preserving concrete expression construction and native typing. Correct two defects also present in upstream: wildcard morph queries must compare a null relationship count of zero in SQL for row-dependent expressions, and nested morph traversal must retain an independent remaining path for each type. Preserve integer EXISTS optimization, callback grouping, nested absence semantics and relationship timeout enforcement without new shared state or extra database queries. Normalize numeric aggregate expressions only when deriving textual aliases, fixing a Hypervel strict-typing TypeError for valid COUNT(1) and SUM(1.5) expressions. Retain and extend upstream nested and nullable-morph tests, add contract and result-set regressions, update type fixtures and document raw count expressions. Upstream: https://github.com/laravel/framework/pull/57830 Related behavior and preserved tests: https://github.com/laravel/framework/pull/54363 https://github.com/laravel/framework/pull/57937 https://github.com/laravel/framework/pull/56512 Validation: changed test classes and affected relationship ParaTest suite pass on SQLite; full source and type-fixture PHPStan, formatting and diff checks pass. Reviewed before commit. --- .../Concerns/QueriesRelationships.php | 122 +++++++++--------- src/docs/eloquent-relationships.md | 8 ++ .../Database/DatabaseEloquentBuilderTest.php | 95 +++++++++++--- .../Database/EloquentWhereHasMorphTest.php | 61 +++++++++ types/Database/Eloquent/Builder.php | 39 ++++-- 5 files changed, 237 insertions(+), 88 deletions(-) diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index e5b800333a..ccebbb6ed4 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Closure; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\ClassMorphViolationException; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; @@ -38,7 +39,7 @@ trait QueriesRelationships * * @throws RuntimeException */ - public function has(Relation|string $relation, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static + public function has(Relation|string $relation, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static { if (is_string($relation)) { if (str_contains($relation, '.')) { @@ -85,15 +86,13 @@ public function has(Relation|string $relation, string $operator = '>=', Expressi /** * Add nested relationship count / exists conditions to the query. * - * Sets up recursive call to whereHas until we finish the nested relation. + * Set up recursive calls to has until we finish the nested relation. * * @param (\Closure(\Hypervel\Database\Eloquent\Builder<*>): mixed)|null $callback */ - protected function hasNested(string $relations, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static + protected function hasNested(string $relations, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static { - $relations = explode('.', $relations); - - $initialRelations = [...$relations]; + [$relation, $remaining] = explode('.', $relations, 2); $doesntHave = $operator === '<' && $count === 1; @@ -102,23 +101,10 @@ protected function hasNested(string $relations, string $operator = '>=', Express $count = 1; } - $closure = function ($q) use (&$closure, &$relations, $operator, $count, $callback, $initialRelations) { - // If the same closure is called multiple times, reset the relation array to loop through them again... - if ($count === 1 && empty($relations)) { - $relations = [...$initialRelations]; - - array_shift($relations); - } - - // In order to nest "has", we need to add count relation constraints on the - // callback Closure. We'll do this by simply passing the Closure its own - // reference to itself so it calls itself recursively on each segment. - count($relations) > 1 - ? $q->whereHas(array_shift($relations), $closure) - : $q->has(array_shift($relations), $operator, $count, 'and', $callback); - }; + // Each morph type must traverse the same remaining path independently. + $closure = static fn (Builder $query): Builder => $query->has($remaining, $operator, $count, 'and', $callback); - return $this->has(array_shift($relations), $doesntHave ? '<' : '>=', 1, $boolean, $closure); + return $this->has($relation, $doesntHave ? '<' : '>=', 1, $boolean, $closure); } /** @@ -126,7 +112,7 @@ protected function hasNested(string $relations, string $operator = '>=', Express * * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $relation */ - public function orHas(Relation|string $relation, string $operator = '>=', Expression|int $count = 1): static + public function orHas(Relation|string $relation, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->has($relation, $operator, $count, 'or'); } @@ -162,7 +148,7 @@ public function orDoesntHave(Relation|string $relation): static * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback */ - public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + public function whereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->has($relation, $operator, $count, 'and', $callback); } @@ -174,7 +160,7 @@ public function whereHas(Relation|string $relation, ?Closure $callback = null, s * * @param (\Closure(\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>): mixed)|null $callback */ - public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count) ->with($callback ? [$relation => fn ($query) => $callback($query)] : $relation); @@ -188,7 +174,7 @@ public function withWhereHas(string $relation, ?Closure $callback = null, string * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback */ - public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + public function orWhereHas(Relation|string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->has($relation, $operator, $count, 'or', $callback); } @@ -228,7 +214,7 @@ public function orWhereDoesntHave(Relation|string $relation, ?Closure $callback * @param array|string $types * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback */ - public function hasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static + public function hasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and', ?Closure $callback = null): static { if (is_string($relation)) { $relation = $this->getRelationWithoutConstraints($relation); @@ -237,10 +223,15 @@ public function hasMorph(MorphTo|string $relation, string|array $types, string $ $types = (array) $types; $checkMorphNull = $types === ['*'] - && (($operator === '<' && $count >= 1) - || ($operator === '<=' && $count >= 0) - || ($operator === '=' && $count === 0) - || ($operator === '!=' && $count >= 1)); + && ($count instanceof ExpressionContract || match ($operator) { + '=', '<=>' => $count === 0, + '!=', '<>' => $count !== 0, + '<' => 0 < $count, + '<=' => 0 <= $count, + '>' => 0 > $count, + '>=' => 0 >= $count, + default => false, + }); if ($types === ['*']) { // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) @@ -275,7 +266,16 @@ public function hasMorph(MorphTo|string $relation, string|array $types, string $ }); } - $query->when($checkMorphNull, fn (self $query) => $query->orWhereMorphedTo($relation, null)); + $query->when($checkMorphNull, static function (self $query) use ($relation, $operator, $count): void { + if ($count instanceof ExpressionContract) { + // SQL must compare a null morph's zero count with the expression for each row. + $query->orWhere(static fn (self $query): self => $query + ->whereMorphedTo($relation, null) + ->where(new Expression('0'), $operator, $count)); + } else { + $query->orWhereMorphedTo($relation, null); + } + }); }, null, null, $boolean); } @@ -311,7 +311,7 @@ protected function getBelongsToRelation(MorphTo $relation, string $type): Belong * @param \Hypervel\Database\Eloquent\Relations\MorphTo<*, *>|string $relation * @param array|string $types */ - public function orHasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', Expression|int $count = 1): static + public function orHasMorph(MorphTo|string $relation, string|array $types, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->hasMorph($relation, $types, $operator, $count, 'or'); } @@ -350,7 +350,7 @@ public function orDoesntHaveMorph(MorphTo|string $relation, string|array $types) * @param array|string $types * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback */ - public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->hasMorph($relation, $types, $operator, $count, 'and', $callback); } @@ -364,7 +364,7 @@ public function whereHasMorph(MorphTo|string $relation, string|array $types, ?Cl * @param array|string $types * @param null|(Closure(\Hypervel\Database\Eloquent\Builder, string): mixed) $callback */ - public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', Expression|int $count = 1): static + public function orWhereHasMorph(MorphTo|string $relation, string|array $types, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { return $this->hasMorph($relation, $types, $operator, $count, 'or', $callback); } @@ -403,9 +403,9 @@ public function orWhereDoesntHaveMorph(MorphTo|string $relation, string|array $t * @template TRelatedModel of \Hypervel\Database\Eloquent\Model * * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function whereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function whereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->whereHas($relation, function ($query) use ($column, $operator, $value) { if ($column instanceof Closure) { @@ -421,7 +421,7 @@ public function whereRelation(Relation|string $relation, Closure|string|array|Ex * * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $relation */ - public function withWhereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function withWhereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->whereRelation($relation, $column, $operator, $value) ->with([ @@ -437,9 +437,9 @@ public function withWhereRelation(Relation|string $relation, Closure|string|arra * @template TRelatedModel of \Hypervel\Database\Eloquent\Model * * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function orWhereRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function orWhereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->orWhereHas($relation, function ($query) use ($column, $operator, $value) { if ($column instanceof Closure) { @@ -456,9 +456,9 @@ public function orWhereRelation(Relation|string $relation, Closure|string|array| * @template TRelatedModel of \Hypervel\Database\Eloquent\Model * * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function whereDoesntHaveRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->whereDoesntHave($relation, function ($query) use ($column, $operator, $value) { if ($column instanceof Closure) { @@ -475,9 +475,9 @@ public function whereDoesntHaveRelation(Relation|string $relation, Closure|strin * @template TRelatedModel of \Hypervel\Database\Eloquent\Model * * @param \Hypervel\Database\Eloquent\Relations\Relation|string $relation - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->orWhereDoesntHave($relation, function ($query) use ($column, $operator, $value) { if ($column instanceof Closure) { @@ -495,9 +495,9 @@ public function orWhereDoesntHaveRelation(Relation|string $relation, Closure|str * * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation * @param array|string $types - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function whereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function whereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->whereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { $query->where($column, $operator, $value); @@ -511,9 +511,9 @@ public function whereMorphRelation(MorphTo|string $relation, string|array $types * * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation * @param array|string $types - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function orWhereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function orWhereMorphRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->orWhereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { $query->where($column, $operator, $value); @@ -527,9 +527,9 @@ public function orWhereMorphRelation(MorphTo|string $relation, string|array $typ * * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation * @param array|string $types - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->whereDoesntHaveMorph($relation, $types, function ($query) use ($column, $operator, $value) { $query->where($column, $operator, $value); @@ -543,9 +543,9 @@ public function whereMorphDoesntHaveRelation(MorphTo|string $relation, string|ar * * @param \Hypervel\Database\Eloquent\Relations\MorphTo|string $relation * @param array|string $types - * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|\Hypervel\Database\Query\Expression|string $column + * @param array|(Closure(\Hypervel\Database\Eloquent\Builder): mixed)|ExpressionContract|string $column */ - public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|Expression $column, mixed $operator = null, mixed $value = null): static + public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string|array $types, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->orWhereDoesntHaveMorph($relation, $types, function ($query) use ($column, $operator, $value) { $query->where($column, $operator, $value); @@ -796,7 +796,7 @@ public function orWhereAttachedTo(mixed $related, ?string $relationshipName = nu /** * Add subselect queries to include an aggregate value for a relationship. */ - public function withAggregate(mixed $relations, Expression|string $column, ?string $function = null): static + public function withAggregate(mixed $relations, ExpressionContract|string $column, ?string $function = null): static { if (empty($relations)) { return $this; @@ -868,7 +868,7 @@ public function withAggregate(mixed $relations, Expression|string $column, ?stri preg_replace( '/[^[:alnum:][:space:]_]/u', '', - sprintf('%s %s %s', $name, $function, strtolower($this->getQuery()->getGrammar()->getValue($column))) + sprintf('%s %s %s', $name, $function, strtolower((string) $this->getQuery()->getGrammar()->getValue($column))) ) ); @@ -921,7 +921,7 @@ public function withCount(mixed $relations): static /** * Add subselect queries to include the max of the relation's column. */ - public function withMax(string|array $relation, Expression|string $column): static + public function withMax(string|array $relation, ExpressionContract|string $column): static { return $this->withAggregate($relation, $column, 'max'); } @@ -929,7 +929,7 @@ public function withMax(string|array $relation, Expression|string $column): stat /** * Add subselect queries to include the min of the relation's column. */ - public function withMin(string|array $relation, Expression|string $column): static + public function withMin(string|array $relation, ExpressionContract|string $column): static { return $this->withAggregate($relation, $column, 'min'); } @@ -937,7 +937,7 @@ public function withMin(string|array $relation, Expression|string $column): stat /** * Add subselect queries to include the sum of the relation's column. */ - public function withSum(string|array $relation, Expression|string $column): static + public function withSum(string|array $relation, ExpressionContract|string $column): static { return $this->withAggregate($relation, $column, 'sum'); } @@ -945,7 +945,7 @@ public function withSum(string|array $relation, Expression|string $column): stat /** * Add subselect queries to include the average of the relation's column. */ - public function withAvg(string|array $relation, Expression|string $column): static + public function withAvg(string|array $relation, ExpressionContract|string $column): static { return $this->withAggregate($relation, $column, 'avg'); } @@ -964,7 +964,7 @@ public function withExists(string|array $relation): static * @param \Hypervel\Database\Eloquent\Builder<*> $hasQuery * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *> $relation */ - protected function addHasWhere(Builder $hasQuery, Relation $relation, string $operator, Expression|int $count, string $boolean): static + protected function addHasWhere(Builder $hasQuery, Relation $relation, string $operator, ExpressionContract|int $count, string $boolean): static { $hasQuery->mergeConstraintsFrom($relation->getQuery()); $query = $hasQuery->toBase(); @@ -1022,7 +1022,7 @@ protected function requalifyWhereTables(array $wheres, string $from, string $to) /** * Add a sub-query count clause to this query. */ - protected function addWhereCountQuery(QueryBuilder $query, string $operator = '>=', Expression|int $count = 1, string $boolean = 'and'): static + protected function addWhereCountQuery(QueryBuilder $query, string $operator = '>=', ExpressionContract|int $count = 1, string $boolean = 'and'): static { $this->query->addBinding($query->getBindings(), 'where'); @@ -1063,7 +1063,7 @@ protected function getRelationWithoutConstraints(string $relation): Relation /** * Check if we can run an "exists" query to optimize performance. */ - protected function canUseExistsForExistenceCheck(string $operator, Expression|int $count): bool + protected function canUseExistsForExistenceCheck(string $operator, ExpressionContract|int $count): bool { return ($operator === '>=' || $operator === '<') && $count === 1; } diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 16c71e1b6b..0b5601d274 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -1624,6 +1624,14 @@ You may also specify an operator and count value to further customize the query: $posts = Post::has('comments', '>=', 3)->get(); ``` +You may pass a raw expression as the count to compare against another column: + +```php +use Hypervel\Support\Facades\DB; + +$posts = Post::has('comments', '>=', DB::raw('posts.required_comments'))->get(); +``` + Nested `has` statements may be constructed using "dot" notation. For example, you may retrieve all posts that have at least one comment that has at least one image: ```php diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index a08104d15b..e7a327e2e3 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Closure; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\BinaryParameter; use Hypervel\Database\ClassMorphViolationException; use Hypervel\Database\Connection; @@ -29,6 +30,7 @@ use InvalidArgumentException; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; use Stringable; @@ -1781,6 +1783,20 @@ public function testWithAggregateAlias() ); } + public function testWithAggregateNumericExpression(): void + { + $model = new ModelParentStub; + + $this->assertSame( + 'select "model_parent_stubs".*, (select count(1) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_count1" from "model_parent_stubs"', + $model->withAggregate('foo', new Expression(1), 'count')->toSql() + ); + $this->assertSame( + 'select "model_parent_stubs".*, (select sum(1.5) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_sum15" from "model_parent_stubs"', + $model->withSum('foo', new Expression(1.5))->toSql() + ); + } + public function testWithAggregateAndSelfRelationConstrain() { Stub::resolveRelationUsing('children', function ($model) { @@ -2051,7 +2067,8 @@ public function testHasNested() $this->assertEquals($builder->toSql(), $result); } - public function testHasNestedWithMorphTo() + #[DataProvider('nestedRelationshipCountProvider')] + public function testHasNestedWithMorphTo(ExpressionContract|int $count): void { $model = new ModelParentStub; $connection = $this->mockConnectionForModel($model, ''); @@ -2063,16 +2080,15 @@ public function testHasNestedWithMorphTo() [$morphToKey => ModelOtherFarRelatedStub::class], ]); - $builder = $model->orWhereHasMorph('morph', [ModelFarRelatedStub::class], function ($q) { - $q->has('baz'); - })->orWhereHasMorph('morph', [ModelOtherFarRelatedStub::class], function ($q) { - $q->has('baz'); + $builder = $model->orWhereHasMorph('morph', [ModelFarRelatedStub::class], function ($q) use ($count) { + $q->has('baz', '>=', $count); + })->orWhereHasMorph('morph', [ModelOtherFarRelatedStub::class], function ($q) use ($count) { + $q->has('baz', '>=', $count); }); - $results = $model->has('morph.baz')->toSql(); + $results = $model->has('morph.baz', '>=', $count)->toSql(); - // we need to adjust the expected builder because some parathesis are added, - // which doesn't impact the behavior of the test. + // Normalize the extra parentheses around the wildcard's grouped morph types. $builderSql = $builder->toSql(); $builderSql = str_replace(')))) or ((', '))) or (', $builderSql); @@ -2080,7 +2096,8 @@ public function testHasNestedWithMorphTo() $this->assertSame($builderSql, $results); } - public function testHasNestedWithMorphToAndMultipleSubRelations() + #[DataProvider('nestedRelationshipCountProvider')] + public function testHasNestedWithMorphToAndMultipleSubRelations(ExpressionContract|int $count): void { $model = new ModelParentStub; $connection = $this->mockConnectionForModel($model, ''); @@ -2092,16 +2109,15 @@ public function testHasNestedWithMorphToAndMultipleSubRelations() [$morphToKey => ModelOtherFarRelatedStub::class], ]); - $builder = $model->orWhereHasMorph('morph', [ModelFarRelatedStub::class], function ($q) { - $q->has('baz.bam'); - })->orWhereHasMorph('morph', [ModelOtherFarRelatedStub::class], function ($q) { - $q->has('baz.bam'); + $builder = $model->orWhereHasMorph('morph', [ModelFarRelatedStub::class], function ($q) use ($count) { + $q->has('baz.bam', '>=', $count); + })->orWhereHasMorph('morph', [ModelOtherFarRelatedStub::class], function ($q) use ($count) { + $q->has('baz.bam', '>=', $count); }); - $results = $model->has('morph.baz.bam')->toSql(); + $results = $model->has('morph.baz.bam', '>=', $count)->toSql(); - // we need to adjust the expected builder because some parathesis are added, - // which doesn't impact the behavior of the test. + // Normalize the extra parentheses around the wildcard's grouped morph types. $builderSql = $builder->toSql(); $builderSql = str_replace(')))) or ((', '))) or (', $builderSql); @@ -2109,6 +2125,53 @@ public function testHasNestedWithMorphToAndMultipleSubRelations() $this->assertSame($builderSql, $results); } + /** + * Provide counts that must survive each polymorphic branch. + */ + public static function nestedRelationshipCountProvider(): array + { + return [ + 'default count' => [1], + 'integer count' => [2], + 'expression count' => [new Expression('2')], + ]; + } + + public function testHasNestedWithMorphToAfterFirstRelation(): void + { + ModelCloseRelatedStub::resolveRelationUsing('morph', static fn (ModelCloseRelatedStub $model) => $model->morphTo('morph')); + + $model = new ModelParentStub; + $connection = $this->mockConnectionForModel($model, ''); + $connection->shouldReceive('select')->once()->andReturn([ + ['morph_type' => ModelFarRelatedStub::class], + ['morph_type' => ModelOtherFarRelatedStub::class], + ]); + + $expected = $model->whereHas('foo', static function (Builder $query): void { + $query->whereHasMorph('morph', [ModelFarRelatedStub::class, ModelOtherFarRelatedStub::class], static function (Builder $query): void { + $query->has('baz'); + }); + }); + $actual = $model->whereHas('foo.morph.baz'); + + $this->assertSame($expected->toSql(), $actual->toSql()); + $this->assertSame([ModelFarRelatedStub::class, ModelOtherFarRelatedStub::class], $actual->getBindings()); + } + + public function testHasWithCustomCountExpression(): void + { + $count = m::mock(ExpressionContract::class); + $count->shouldReceive('getValue')->andReturn('model_parent_stubs.required_count'); + + $query = (new ModelParentStub)->whereHas('foo', static function (Builder $query): void { + $query->where('active', true); + }, '>=', $count); + + $this->assertSame('select * from "model_parent_stubs" where (select count(*) from "model_close_related_stubs" where ("model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") and ("active" = ?)) >= model_parent_stubs.required_count', $query->toSql()); + $this->assertSame([true], $query->getBindings()); + } + public function testOrHasNested() { $model = new ModelParentStub; diff --git a/tests/Integration/Database/EloquentWhereHasMorphTest.php b/tests/Integration/Database/EloquentWhereHasMorphTest.php index 776b1dd351..78c1a3170d 100644 --- a/tests/Integration/Database/EloquentWhereHasMorphTest.php +++ b/tests/Integration/Database/EloquentWhereHasMorphTest.php @@ -4,13 +4,17 @@ namespace Hypervel\Tests\Integration\Database\EloquentWhereHasMorphTest; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Integration\Database\DatabaseTestCase; +use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class EloquentWhereHasMorphTest extends DatabaseTestCase { @@ -94,6 +98,63 @@ public function testWhereHasMorphWithWildcard() $this->assertEquals([1, 4], $comments->pluck('id')->all()); } + #[DataProvider('wildcardCountComparisonProvider')] + public function testWhereHasMorphWithWildcardCountComparisons(string $operator, array $zeroIds, array $columnIds): void + { + $this->assertSame($zeroIds, Comment::whereHasMorph('commentable', '*', null, $operator, 0) + ->orderBy('id')->pluck('id')->all()); + + $this->assertSame($zeroIds, Comment::whereHasMorph('commentable', '*', null, $operator, new Expression('0')) + ->orderBy('id')->pluck('id')->all()); + + $count = m::mock(ExpressionContract::class); + $count->shouldReceive('getValue')->andReturn('comments.id - 7'); + + $query = Comment::whereHasMorph('commentable', '*', null, $operator, $count)->orderBy('id'); + + $this->assertSame($columnIds, $query->pluck('id')->all()); + $this->assertSame([Post::class, Video::class], $query->getBindings()); + } + + /** + * Provide zero-count and row-dependent comparisons, including nullable morphs. + */ + public static function wildcardCountComparisonProvider(): array + { + return [ + 'equal' => ['=', [3, 7, 8], [7]], + 'null-safe equal' => ['<=>', [3, 7, 8], [7]], + 'not equal' => ['!=', [1, 2, 4, 5, 6], [1, 2, 3, 4, 5, 6, 8]], + 'alternate not equal' => ['<>', [1, 2, 4, 5, 6], [1, 2, 3, 4, 5, 6, 8]], + 'less than' => ['<', [], [8]], + 'less than or equal' => ['<=', [3, 7, 8], [7, 8]], + 'greater than' => ['>', [1, 2, 4, 5, 6], [1, 2, 3, 4, 5, 6]], + 'greater than or equal' => ['>=', [1, 2, 3, 4, 5, 6, 7, 8], [1, 2, 3, 4, 5, 6, 7]], + ]; + } + + public function testWhereHasMorphWithExpressionCountAndOnlyNullMorphTypes(): void + { + Comment::whereNotNull('commentable_type')->forceDelete(); + + $this->assertSame([7], Comment::whereHasMorph('commentable', '*', null, '=', new Expression('comments.id - 7')) + ->orderBy('id')->pluck('id')->all()); + } + + public function testWhereHasMorphWithExpressionCountAndExplicitTypes(): void + { + $this->assertSame([3], Comment::whereHasMorph('commentable', [Post::class, Video::class], null, '=', new Expression('0')) + ->orderBy('id')->pluck('id')->all()); + } + + public function testWhereHasMorphWithExpressionCountIsLogicallyGrouped(): void + { + $this->assertSame([], Comment::whereNot('title', 'foo') + ->whereHasMorph('commentable', '*', null, '=', new Expression('0'))->pluck('id')->all()); + $this->assertSame([], Comment::whereHasMorph('commentable', '*', null, '=', new Expression('0')) + ->whereNot('title', 'foo')->pluck('id')->all()); + } + public function testWhereHasMorphWithWildcardAndMorphMap() { Relation::morphMap(['posts' => Post::class]); diff --git a/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index 5657957669..cc22c5d93a 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -4,6 +4,7 @@ namespace Hypervel\Types\Builder; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\HasBuilder; use Hypervel\Database\Eloquent\Model; @@ -21,7 +22,8 @@ function test( Post $post, ChildPost $childPost, Comment $comment, - QueryBuilder $queryBuilder + QueryBuilder $queryBuilder, + Expression $expression ): void { assertType('Hypervel\Database\Eloquent\Builder', $query->where('id', 1)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhere('name', 'John')); @@ -89,37 +91,37 @@ function test( assertType('Hypervel\Database\Eloquent\Relations\Relation', $query->getRelation('foo')); assertType('Hypervel\Database\Eloquent\Builder', $query->setModel(new Post)); - assertType('Hypervel\Database\Eloquent\Builder', $query->has('foo', callback: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query->has('foo', count: $expression, callback: function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); })); - assertType('Hypervel\Database\Eloquent\Builder', $query->has($user->posts(), callback: function ($query) { + assertType('Hypervel\Database\Eloquent\Builder', $query->has($user->posts(), count: $expression, callback: function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); })); - assertType('Hypervel\Database\Eloquent\Builder', $query->orHas($user->posts())); + assertType('Hypervel\Database\Eloquent\Builder', $query->orHas($user->posts(), count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->doesntHave($user->posts(), callback: function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orDoesntHave($user->posts())); assertType('Hypervel\Database\Eloquent\Builder', $query->whereHas($user->posts(), function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); - })); + }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->withWhereHas('posts', function ($query) { assertType('Hypervel\Database\Eloquent\Builder<*>|Hypervel\Database\Eloquent\Relations\Relation<*, *, *>', $query); - })); + }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereHas($user->posts(), function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); - })); + }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->whereDoesntHave($user->posts(), function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); })); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereDoesntHave($user->posts(), function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); })); - assertType('Hypervel\Database\Eloquent\Builder', $query->hasMorph($post->taggable(), 'taggable', callback: function ($query, $type) { + assertType('Hypervel\Database\Eloquent\Builder', $query->hasMorph($post->taggable(), 'taggable', count: $expression, callback: function ($query, $type) { assertType('Hypervel\Database\Eloquent\Builder', $query); assertType('string', $type); })); - assertType('Hypervel\Database\Eloquent\Builder', $query->orHasMorph($post->taggable(), 'taggable')); + assertType('Hypervel\Database\Eloquent\Builder', $query->orHasMorph($post->taggable(), 'taggable', count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->doesntHaveMorph($post->taggable(), 'taggable', callback: function ($query, $type) { assertType('Hypervel\Database\Eloquent\Builder', $query); assertType('string', $type); @@ -128,11 +130,11 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query->whereHasMorph($post->taggable(), 'taggable', function ($query, $type) { assertType('Hypervel\Database\Eloquent\Builder', $query); assertType('string', $type); - })); + }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereHasMorph($post->taggable(), 'taggable', function ($query, $type) { assertType('Hypervel\Database\Eloquent\Builder', $query); assertType('string', $type); - })); + }, count: $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->whereDoesntHaveMorph($post->taggable(), 'taggable', function ($query, $type) { assertType('Hypervel\Database\Eloquent\Builder', $query); assertType('string', $type); @@ -165,6 +167,21 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphDoesntHaveRelation($post->taggable(), 'taggable', function ($query) { assertType('Hypervel\Database\Eloquent\Builder', $query); })); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereRelation('posts', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->withWhereRelation('posts', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereRelation('posts', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereDoesntHaveRelation('posts', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereDoesntHaveRelation('posts', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphRelation($post->taggable(), 'taggable', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphRelation($post->taggable(), 'taggable', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphDoesntHaveRelation($post->taggable(), 'taggable', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphDoesntHaveRelation($post->taggable(), 'taggable', $expression, '=', 1)); + assertType('Hypervel\Database\Eloquent\Builder', $query->withAggregate('posts', $expression, 'sum')); + assertType('Hypervel\Database\Eloquent\Builder', $query->withMax('posts', $expression)); + assertType('Hypervel\Database\Eloquent\Builder', $query->withMin('posts', $expression)); + assertType('Hypervel\Database\Eloquent\Builder', $query->withSum('posts', $expression)); + assertType('Hypervel\Database\Eloquent\Builder', $query->withAvg('posts', $expression)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphedTo($post->taggable(), new Post)); assertType('Hypervel\Database\Eloquent\Builder', $query->whereNotMorphedTo($post->taggable(), new Post)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphedTo($post->taggable(), new Post)); From 262b5429e3571ed6d35e7abb134a04f44b538033 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:04:56 +0000 Subject: [PATCH 02/23] Fix expression and callback types and document relationship queries Complete the current Laravel relationship and callback typing updates from 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: - https://github.com/laravel/framework/pull/57896 - https://github.com/laravel/framework/pull/49912 - https://github.com/laravel/framework/pull/53996 - https://github.com/laravel/framework/pull/54668 - https://github.com/laravel/framework/pull/60782 Deferred relationship aggregates rejected expression columns even though their query-builder counterparts already accepted them. Accept the query Expression contract at every Model and Collection forwarding boundary, including polymorphic aggregates. Preserve existing aggregate SQL, keyed model matching, original attribute synchronization, casts and query counts. Cover the real deferred path with an integration regression and protect each forwarding signature in the existing type fixtures. Match the current violation and exception-configuration callback return types without constraining callers to void callbacks. Restore the upstream HTTP callback and returned-handler exception annotations. Describe password rule iterator keys as array-key: named custom rules preserve string keys, so upstream's integer-only annotation is still too narrow. The exponent-policy setter also incorrectly accepted only Closure although Laravel supports every callable form. Accept callable and convert once to a first-class closure at registration, retaining the typed property and unchanged compiled and delegated validation paths. Verify an invokable policy can both permit and reject validation. Document the existing inline relationship absence and constrained eager loading APIs, plus raw aggregate expressions and their deferred forms. The local Laravel documentation has no corresponding passages. Apply the required import, method-title and boot-time registration documentation conventions without changing container or callback lifetime behavior. Validation: changed PHPUnit files and type fixtures; affected Eloquent, HTTP, Foundation and validation suites through ParaTest; composer lint:fix; full composer analyse; git diff --check. Peer review approved the complete diff, including the corrected password iterator key type. --- src/database/src/Eloquent/Collection.php | 11 ++++--- src/database/src/Eloquent/Model.php | 33 ++++++++++--------- src/docs/eloquent-relationships.md | 22 +++++++++++++ .../src/Configuration/ApplicationBuilder.php | 13 +++++--- src/http/src/Client/PendingRequest.php | 4 +++ src/queue/src/Middleware/Skip.php | 3 ++ src/validation/src/Rules/Password.php | 2 ++ src/validation/src/Validator.php | 6 ++-- .../Database/EloquentModelLoadSumTest.php | 16 +++++++++ tests/Validation/ValidationValidatorTest.php | 24 ++++++++++++++ types/Database/Eloquent/Collection.php | 17 ++++++++++ types/Database/Eloquent/Model.php | 18 +++++++++- 12 files changed, 141 insertions(+), 28 deletions(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 81723346af..bd20cb174f 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -5,6 +5,7 @@ namespace Hypervel\Database\Eloquent; use Closure; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Contracts\Queue\QueueableCollection; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; @@ -123,7 +124,7 @@ public function load(array|string $relations): static * * @throws MissingAttributeException */ - public function loadAggregate(array|string $relations, string $column, ?string $function = null): static + public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static { if ($this->isEmpty()) { return $this; @@ -193,7 +194,7 @@ public function loadCount(array|string $relations): static * * @param array): mixed)|string>|string $relations */ - public function loadMax(array|string $relations, string $column): static + public function loadMax(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'max'); } @@ -203,7 +204,7 @@ public function loadMax(array|string $relations, string $column): static * * @param array): mixed)|string>|string $relations */ - public function loadMin(array|string $relations, string $column): static + public function loadMin(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'min'); } @@ -213,7 +214,7 @@ public function loadMin(array|string $relations, string $column): static * * @param array): mixed)|string>|string $relations */ - public function loadSum(array|string $relations, string $column): static + public function loadSum(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'sum'); } @@ -223,7 +224,7 @@ public function loadSum(array|string $relations, string $column): static * * @param array): mixed)|string>|string $relations */ - public function loadAvg(array|string $relations, string $column): static + public function loadAvg(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'avg'); } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index dd0a851978..0b2d7815e7 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -9,6 +9,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Broadcasting\HasBroadcastChannel; use Hypervel\Contracts\Container\Transient; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\QueueableCollection; use Hypervel\Contracts\Queue\QueueableEntity; @@ -224,7 +225,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling lazy loading violations. * - * @var null|(callable(self, string): void) + * @var null|(callable(self, string): mixed) */ protected static $lazyLoadingViolationCallback; @@ -236,7 +237,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling discarded attribute violations. * - * @var null|(callable(self, array): void) + * @var null|(callable(self, array): mixed) */ protected static $discardedAttributeViolationCallback; @@ -248,7 +249,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling missing attribute violations. * - * @var null|(callable(self, string): void) + * @var null|(callable(self, string): mixed) */ protected static $missingAttributeViolationCallback; @@ -664,7 +665,7 @@ public static function automaticallyEagerLoadRelationships(bool $value = true): * Boot-only. The callback persists in a static property for the worker * lifetime and runs on every lazy-loading violation across all coroutines. * - * @param null|(callable(self, string): void) $callback + * @param null|(callable(self, string): mixed) $callback */ public static function handleLazyLoadingViolationUsing(?callable $callback): void { @@ -689,7 +690,7 @@ public static function preventSilentlyDiscardingAttributes(bool $value = true): * lifetime and runs on every discarded-attribute violation across all * coroutines. * - * @param null|(callable(self, array): void) $callback + * @param null|(callable(self, array): mixed) $callback */ public static function handleDiscardedAttributeViolationUsing(?callable $callback): void { @@ -714,7 +715,7 @@ public static function preventAccessingMissingAttributes(bool $value = true): vo * lifetime and runs on every missing-attribute violation across all * coroutines. * - * @param null|(callable(self, string): void) $callback + * @param null|(callable(self, string): mixed) $callback */ public static function handleMissingAttributeViolationUsing(?callable $callback): void { @@ -982,7 +983,7 @@ public function loadMissing(array|string $relations): static * * @param array|string $relations */ - public function loadAggregate(array|string $relations, string $column, ?string $function = null): static + public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static { $this->newCollection([$this])->loadAggregate($relations, $column, $function); @@ -1006,7 +1007,7 @@ public function loadCount(array|string $relations): static * * @param array|string $relations */ - public function loadMax(array|string $relations, string $column): static + public function loadMax(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'max'); } @@ -1016,7 +1017,7 @@ public function loadMax(array|string $relations, string $column): static * * @param array|string $relations */ - public function loadMin(array|string $relations, string $column): static + public function loadMin(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'min'); } @@ -1026,7 +1027,7 @@ public function loadMin(array|string $relations, string $column): static * * @param array|string $relations */ - public function loadSum(array|string $relations, string $column): static + public function loadSum(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'sum'); } @@ -1036,7 +1037,7 @@ public function loadSum(array|string $relations, string $column): static * * @param array|string $relations */ - public function loadAvg(array|string $relations, string $column): static + public function loadAvg(array|string $relations, Expression|string $column): static { return $this->loadAggregate($relations, $column, 'avg'); } @@ -1056,7 +1057,7 @@ public function loadExists(array|string $relations): static * * @param array> $relations */ - public function loadMorphAggregate(string $relation, array $relations, string $column, ?string $function = null): static + public function loadMorphAggregate(string $relation, array $relations, Expression|string $column, ?string $function = null): static { if (! $this->{$relation}) { return $this; @@ -1084,7 +1085,7 @@ public function loadMorphCount(string $relation, array $relations): static * * @param array> $relations */ - public function loadMorphMax(string $relation, array $relations, string $column): static + public function loadMorphMax(string $relation, array $relations, Expression|string $column): static { return $this->loadMorphAggregate($relation, $relations, $column, 'max'); } @@ -1094,7 +1095,7 @@ public function loadMorphMax(string $relation, array $relations, string $column) * * @param array> $relations */ - public function loadMorphMin(string $relation, array $relations, string $column): static + public function loadMorphMin(string $relation, array $relations, Expression|string $column): static { return $this->loadMorphAggregate($relation, $relations, $column, 'min'); } @@ -1104,7 +1105,7 @@ public function loadMorphMin(string $relation, array $relations, string $column) * * @param array> $relations */ - public function loadMorphSum(string $relation, array $relations, string $column): static + public function loadMorphSum(string $relation, array $relations, Expression|string $column): static { return $this->loadMorphAggregate($relation, $relations, $column, 'sum'); } @@ -1114,7 +1115,7 @@ public function loadMorphSum(string $relation, array $relations, string $column) * * @param array> $relations */ - public function loadMorphAvg(string $relation, array $relations, string $column): static + public function loadMorphAvg(string $relation, array $relations, Expression|string $column): static { return $this->loadMorphAggregate($relation, $relations, $column, 'avg'); } diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 0b5601d274..76724bb739 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -1694,6 +1694,14 @@ $posts = Post::whereRelation( )->get(); ``` +To retrieve models that have no related records matching a condition, you may use `whereDoesntHaveRelation` or `orWhereDoesntHaveRelation`. For example, the following query retrieves posts that have no unapproved comments: + +```php +$posts = Post::whereDoesntHaveRelation('comments', 'is_approved', false)->get(); +``` + +The `whereMorphDoesntHaveRelation` and `orWhereMorphDoesntHaveRelation` methods provide the same functionality for polymorphic relationships. + ### Querying Relationship Absence @@ -1902,6 +1910,14 @@ $post = Post::first(); $post->loadSum('comments', 'votes'); ``` +You may also pass a raw expression instead of a column name to the `withMin`, `withMax`, `withAvg`, and `withSum` methods or their deferred counterparts: + +```php +use Hypervel\Support\Facades\DB; + +$posts = Post::withSum('comments as weighted_votes', DB::raw('votes * 2'))->get(); +``` + If you're combining these aggregate methods with a `select` statement, ensure that you call the aggregate methods after the `select` method: ```php @@ -2189,6 +2205,12 @@ $users = User::withWhereHas('posts', function ($query) { })->get(); ``` +For a single, simple condition, you may use the `withWhereRelation` method: + +```php +$users = User::withWhereRelation('posts', 'featured', true)->get(); +``` + ### Lazy Eager Loading diff --git a/src/foundation/src/Configuration/ApplicationBuilder.php b/src/foundation/src/Configuration/ApplicationBuilder.php index f251a76698..a92ba29ffd 100644 --- a/src/foundation/src/Configuration/ApplicationBuilder.php +++ b/src/foundation/src/Configuration/ApplicationBuilder.php @@ -8,9 +8,11 @@ use Hypervel\Console\Application as Artisan; use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Console\Kernel as ConsoleKernel; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Http\Kernel as HttpKernel; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\RegisterProviders; +use Hypervel\Foundation\Exceptions\Handler; use Hypervel\Foundation\Http\HealthCheckController; use Hypervel\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Hypervel\Foundation\Support\Providers\EventServiceProvider as AppEventServiceProvider; @@ -298,18 +300,21 @@ public function withSchedule(callable $callback): static /** * Register and configure the application's exception handler. * - * @param null|(callable(Exceptions): void) $using + * Boot-only. The exception handler binding and resolution callback persist + * in the application container and affect subsequent requests. + * + * @param null|(callable(Exceptions): mixed) $using */ public function withExceptions(?callable $using = null): static { $this->app->singleton( - \Hypervel\Contracts\Debug\ExceptionHandler::class, - \Hypervel\Foundation\Exceptions\Handler::class + ExceptionHandler::class, + Handler::class ); if ($using !== null) { $this->app->afterResolving( - \Hypervel\Foundation\Exceptions\Handler::class, + Handler::class, fn ($handler) => $using(new Exceptions($handler)), ); } diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index e9efc7d6a0..b960c1e6b3 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -686,6 +686,8 @@ public function beforeSending(callable $callback): static /** * Add a new callback to execute after the response is built. + * + * @param callable(Response, Request): (null|Response) $callback */ public function afterResponse(callable $callback): static { @@ -1659,6 +1661,8 @@ function ($reason) use ($request, $options) { /** * Build the stub handler. + * + * @throws StrayRequestException */ public function buildStubHandler(): Closure { diff --git a/src/queue/src/Middleware/Skip.php b/src/queue/src/Middleware/Skip.php index 36a8af4167..418c0d5da5 100644 --- a/src/queue/src/Middleware/Skip.php +++ b/src/queue/src/Middleware/Skip.php @@ -10,6 +10,9 @@ class Skip { + /** + * Create a new middleware instance. + */ public function __construct(protected bool $skip = false) { } diff --git a/src/validation/src/Rules/Password.php b/src/validation/src/Rules/Password.php index cf8b63efdd..4d35bfd183 100644 --- a/src/validation/src/Rules/Password.php +++ b/src/validation/src/Rules/Password.php @@ -411,6 +411,8 @@ public function toPasswordRulesString(): string /** * Get an iterator for the password validation rules. + * + * @return ArrayIterator */ public function getIterator(): Traversable { diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index f33b03d6f7..25b49633b6 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -1960,10 +1960,12 @@ public function setException(string|Throwable $exception): static /** * Ensure exponents are within range using the given callback. + * + * @param callable(int, string, mixed): mixed $callback */ - public function ensureExponentWithinAllowedRangeUsing(Closure $callback): static + public function ensureExponentWithinAllowedRangeUsing(callable $callback): static { - $this->ensureExponentWithinAllowedRangeUsing = $callback; + $this->ensureExponentWithinAllowedRangeUsing = $callback(...); return $this; } diff --git a/tests/Integration/Database/EloquentModelLoadSumTest.php b/tests/Integration/Database/EloquentModelLoadSumTest.php index 2548ddadca..7478f6a239 100644 --- a/tests/Integration/Database/EloquentModelLoadSumTest.php +++ b/tests/Integration/Database/EloquentModelLoadSumTest.php @@ -4,11 +4,13 @@ namespace Hypervel\Tests\Integration\Database\EloquentModelLoadSumTest; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Integration\Database\DatabaseTestCase; +use Mockery as m; class EloquentModelLoadSumTest extends DatabaseTestCase { @@ -49,6 +51,20 @@ public function testLoadSumSingleRelation() $this->assertEquals(21, $model->related1_sum_number); } + public function testLoadSumWithContractExpression(): void + { + $model = BaseModel::first(); + $expression = m::mock(Expression::class); + $expression->shouldReceive('getValue')->andReturn('number * 2'); + + DB::enableQueryLog(); + + $model->loadSum('related1 as total', $expression); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(42, $model->total); + } + public function testLoadSumMultipleRelations() { $model = BaseModel::first(); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index acee2c49f4..e710cb8c95 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -10852,6 +10852,30 @@ public function testItCanConfigureAllowedExponentRange(): void $this->assertFalse($validator->passes()); } + public function testItCanConfigureAllowedExponentRangeUsingCallableObject(): void + { + $validator = new Validator($this->getArrayTranslator(), ['foo' => '1.0e-1000'], ['foo' => ['numeric', 'max:3']]); + $policy = new class { + public bool $allowed = true; + + /** + * Determine whether the exponent is allowed. + */ + public function __invoke(int $scale, string $attribute, mixed $value): bool + { + return $this->allowed; + } + }; + + $validator->ensureExponentWithinAllowedRangeUsing($policy); + + $this->assertTrue($validator->passes()); + + $policy->allowed = false; + + $this->assertFalse($validator->passes()); + } + public function testMessagesDefaultWhenUsingSizeSpecificCustomMessages() { $trans = $this->getArrayTranslator(); diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index b08bbd4d4a..35b0a93c60 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Hypervel\Contracts\Database\Query\Expression; +use Hypervel\Database\Eloquent\Collection; + use function PHPStan\Testing\assertType; $collection = User::all(); @@ -63,6 +66,20 @@ // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); }], 'string')); +/** + * Check expression columns on deferred relationship aggregates. + * + * @param Collection $collection + */ +function assertEloquentCollectionAggregateExpressionTypes(Collection $collection, Expression $expression): void +{ + assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate('posts', $expression, 'sum')); + assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax('posts', $expression)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin('posts', $expression)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum('posts', $expression)); + assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg('posts', $expression)); +} + assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => ['foo' => fn ($q) => $q]])); diff --git a/types/Database/Eloquent/Model.php b/types/Database/Eloquent/Model.php index ab2bda1bda..fe021027ab 100644 --- a/types/Database/Eloquent/Model.php +++ b/types/Database/Eloquent/Model.php @@ -4,6 +4,7 @@ namespace Hypervel\Types\Model; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Eloquent\Attributes\CollectedBy; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\HasCollection; @@ -13,7 +14,7 @@ use function PHPStan\Testing\assertType; -function test(User $user, Post $post, Comment $comment, Article $article, DatabaseNotification $notification): void +function test(User $user, Post $post, Comment $comment, Article $article, DatabaseNotification $notification, Expression $expression): void { assertType('UserFactory', User::factory(function ($attributes, $model) { assertType('array', $attributes); @@ -34,6 +35,10 @@ function test(User $user, Post $post, Comment $comment, Article $article, Databa $builder->where('created_at', '<', now()->subYears(2000)); }); + User::handleLazyLoadingViolationUsing(fn (Model $model, string $key) => 'handled'); + User::handleDiscardedAttributeViolationUsing(fn (Model $model, array $keys) => 'handled'); + User::handleMissingAttributeViolationUsing(fn (Model $model, string $key) => 'default'); + assertType('Hypervel\Database\Eloquent\Builder', User::query()); assertType('Hypervel\Database\Eloquent\Builder', $user->newQuery()); assertType('Hypervel\Database\Eloquent\Builder', $user->withTrashed()); @@ -50,6 +55,17 @@ function test(User $user, Post $post, Comment $comment, Article $article, Databa assertType('Hypervel\Types\Model\Articles<(int|string), Hypervel\Types\Model\Article>', $article->newCollection([new Article])); assertType('Hypervel\Types\Model\Comments', $comment->newCollection([new Comment])); + assertType('User', $user->loadAggregate('posts', $expression, 'sum')); + assertType('User', $user->loadMax('posts', $expression)); + assertType('User', $user->loadMin('posts', $expression)); + assertType('User', $user->loadSum('posts', $expression)); + assertType('User', $user->loadAvg('posts', $expression)); + assertType('User', $user->loadMorphAggregate('parentable', [Post::class => ['comments']], $expression, 'sum')); + assertType('User', $user->loadMorphMax('parentable', [Post::class => ['comments']], $expression)); + assertType('User', $user->loadMorphMin('parentable', [Post::class => ['comments']], $expression)); + assertType('User', $user->loadMorphSum('parentable', [Post::class => ['comments']], $expression)); + assertType('User', $user->loadMorphAvg('parentable', [Post::class => ['comments']], $expression)); + assertType('bool', $user->restore()); assertType('User', $user->restoreOrCreate()); assertType('User', $user->createOrRestore()); From 2c752979950065e7fcbf266a90ad6c4040057669 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:08:42 +0000 Subject: [PATCH 03/23] Complete expression-contract support across database query paths Port the remaining applicable source and tests from Laravel framework PR #44784, using 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/44784 Accept contract-only expressions across connection/table forwarding, raw sources, scalar retrieval, column comparisons, join shortcuts and SQLite blueprint bookkeeping. Preserve expressions in SELECT clauses and resolve scalar values from returned field names rather than interpreting SQL. Eloquent still uses model attribute access, preserving casts and custom accessors. Correct the two attribute guards that rejected a valid '0' key. Convert numeric expression values only at boundaries that require text. Retain explicit logical FROM aliases on each query builder so addSelect, joined grouped pagination counts and relationship aggregates can select the primary source after fromSub or aliasing. Reset aliases when replacing the source and preserve them through ordinary clones. Prefix the table segment of schema-qualified identifiers, not the schema. Keep projectable aliases distinct from arbitrary updatable raw-table identity: do not infer table names from raw SQL or introduce an unqualified wildcard fallback. SQLite's native schema-wildcard restriction is handled by explicit test aliases. Correct impossible native type claims on qualifyColumns and withWhereRelation, and type castAsJson at its driver-neutral escape boundary. Restore all four applicable upstream JSON grammar tests and their assertions. Add regression coverage for contract expressions, numeric projections, model accessors, aliases, bindings, prefixed execution, grouped counts, relationship aggregates, and schema rollback. Use a second parent row to prove that the fromSub relationship-count query actually filters its source. Fix the facade documenter's loss of generic argument variance, which turned Builder<*> into invalid Builder annotations. Preserve the parser's wildcard, covariant and contravariant metadata in the existing conversion; regenerate DB and cover the distinct forms in one end-to-end test. Remove the redundant test-method title docblock under the repository convention. Validation: immediate changed-file PHPUnit runs; broader database units, SQLite integrations and facade-documenter suites; full source and type-fixture PHPStan; formatting and diff checks; read-only lint of all generated facades. Execution against MySQL, MariaDB and PostgreSQL remains CI coverage. --- src/database/src/Connection.php | 9 +- src/database/src/ConnectionInterface.php | 8 +- src/database/src/Eloquent/Builder.php | 28 ++-- .../src/Eloquent/Concerns/HasAttributes.php | 4 +- .../Concerns/QueriesRelationships.php | 6 +- src/database/src/Grammar.php | 2 +- src/database/src/Query/Builder.php | 79 +++++++---- src/database/src/Query/JoinClause.php | 4 +- src/database/src/Schema/BlueprintState.php | 7 +- src/facade-documenter/facade.php | 17 ++- .../Concerns/InteractsWithDatabase.php | 8 +- src/support/src/Facades/DB.php | 4 +- .../Database/DatabaseEloquentBuilderTest.php | 4 +- tests/Database/DatabaseEloquentModelTest.php | 13 ++ tests/Database/DatabaseQueryBuilderTest.php | 86 ++++++++++++ tests/Database/DatabaseQueryGrammarTest.php | 12 ++ .../GenericPreservationTest.php | 81 ++++++++++- .../Concerns/InteractsWithDatabaseTest.php | 131 ++++++++++++++++++ .../Database/EloquentCursorPaginateTest.php | 12 ++ .../Database/EloquentWhereTest.php | 28 ++++ .../Database/EloquentWithCountTest.php | 16 +++ .../Integration/Database/QueryBuilderTest.php | 64 +++++++++ .../Sqlite/DatabaseSchemaBlueprintTest.php | 27 +++- .../Sqlite/DatabaseSchemaBuilderTest.php | 24 ++++ types/Database/Query/Builder.php | 26 ++++ 25 files changed, 634 insertions(+), 66 deletions(-) diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index e4ca35ec45..f26b7aa83f 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -10,7 +10,10 @@ use Exception; use Generator; use Hypervel\Context\NonCopyableContext; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Events\QueryFailed; use Hypervel\Database\Events\TransactionBeginning; @@ -295,8 +298,10 @@ public function getSchemaState(?Filesystem $files = null, ?callable $processFact /** * Begin a fluent query against a database table. + * + * @param Closure|QueryBuilder|EloquentBuilder<*>|Relation<*, *, *>|ExpressionContract|UnitEnum|string $table */ - public function table(Closure|QueryBuilder|UnitEnum|string $table, ?string $as = null): QueryBuilder + public function table(Closure|QueryBuilder|EloquentBuilder|Relation|ExpressionContract|UnitEnum|string $table, ?string $as = null): QueryBuilder { if ($table instanceof UnitEnum) { $table = (string) enum_value($table); @@ -1078,7 +1083,7 @@ protected function event(object $event): void /** * Get a new raw query expression. */ - public function raw(mixed $value): Expression + public function raw(mixed $value): ExpressionContract { return new Expression($value); } diff --git a/src/database/src/ConnectionInterface.php b/src/database/src/ConnectionInterface.php index c1aeab8a7f..1d01cf7f6a 100644 --- a/src/database/src/ConnectionInterface.php +++ b/src/database/src/ConnectionInterface.php @@ -6,8 +6,10 @@ use Closure; use Generator; +use Hypervel\Contracts\Database\Query\Expression; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Query\Builder; -use Hypervel\Database\Query\Expression; use Hypervel\Database\Query\Grammars\Grammar as QueryGrammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\Schema\Builder as SchemaBuilder; @@ -18,8 +20,10 @@ interface ConnectionInterface { /** * Begin a fluent query against a database table. + * + * @param Closure|Builder|EloquentBuilder<*>|Relation<*, *, *>|Expression|UnitEnum|string $table */ - public function table(Closure|Builder|UnitEnum|string $table, ?string $as = null): Builder; + public function table(Closure|Builder|EloquentBuilder|Relation|Expression|UnitEnum|string $table, ?string $as = null): Builder; /** * Get a new raw query expression. diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 567f6c612d..6ecb1d0f7c 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -756,9 +756,7 @@ public function sole(array|string $columns = ['*']): Model public function value(Expression|string $column): mixed { if ($result = $this->first([$column])) { - $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; - - return $result->{Str::afterLast($column, '.')}; + return $this->getValueFromModel($result, $column); } return null; @@ -772,9 +770,7 @@ public function value(Expression|string $column): mixed */ public function soleValue(Expression|string $column): mixed { - $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; - - return $this->sole([$column])->{Str::afterLast($column, '.')}; + return $this->getValueFromModel($this->sole([$column]), $column); } /** @@ -784,9 +780,21 @@ public function soleValue(Expression|string $column): mixed */ public function valueOrFail(Expression|string $column): mixed { - $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + return $this->getValueFromModel($this->firstOrFail([$column]), $column); + } - return $this->firstOrFail([$column])->{Str::afterLast($column, '.')}; + /** + * Get the selected value through the model's attribute accessors. + */ + protected function getValueFromModel(Model $model, Expression|string $column): mixed + { + // The returned field name accounts for aliases and driver-specific names + // for unaliased expressions without interpreting their SQL. + $column = $column instanceof Expression + ? (string) array_key_first($model->getAttributes()) + : Str::afterLast($column, '.'); + + return $model->{$column}; } /** @@ -985,7 +993,7 @@ public function pluck(Expression|string $column, ?string $key = null): BaseColle { $results = $this->toBase()->pluck($column, $key); - $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + $column = $column instanceof Expression ? (string) $column->getValue($this->getGrammar()) : $column; $column = Str::after($column, "{$this->model->getTable()}."); @@ -1924,7 +1932,7 @@ public function qualifyColumn(Expression|string $column): string /** * Qualify the given columns with the model's table. */ - public function qualifyColumns(Expression|array $columns): array + public function qualifyColumns(array $columns): array { return $this->model->qualifyColumns($columns); } diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index fac63cc9d7..ec4566bf47 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -472,7 +472,7 @@ protected function getArrayableItems(array $values): array */ public function hasAttribute(string $key): bool { - if (! $key) { + if ($key === '') { return false; } @@ -488,7 +488,7 @@ public function hasAttribute(string $key): bool */ public function getAttribute(string $key): mixed { - if (! $key) { + if ($key === '') { return null; } diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index ccebbb6ed4..5bb3ca9eda 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -418,10 +418,8 @@ public function whereRelation(Relation|string $relation, Closure|string|array|Ex /** * Add a basic where clause to a relationship query and eager-load the relationship with the same conditions. - * - * @param \Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $relation */ - public function withWhereRelation(Relation|string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static + public function withWhereRelation(string $relation, Closure|string|array|ExpressionContract $column, mixed $operator = null, mixed $value = null): static { return $this->whereRelation($relation, $column, $operator, $value) ->with([ @@ -875,7 +873,7 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum $this->ensureNoTimeoutOnRelationshipConstraint($query); if (is_null($this->query->columns)) { - $this->query->select([$this->query->from . '.*']); + $this->query->select([$this->query->getDefaultSelectColumn()]); } if ($function === 'exists') { diff --git a/src/database/src/Grammar.php b/src/database/src/Grammar.php index 840541a2a9..f83e648b32 100755 --- a/src/database/src/Grammar.php +++ b/src/database/src/Grammar.php @@ -133,7 +133,7 @@ protected function wrapAliasedTable(string $value, ?string $prefix = null): stri protected function wrapSegments(array $segments): string { return (new Collection($segments))->map(function ($segment, $key) use ($segments) { - return $key === 0 && count($segments) > 1 + return $key === count($segments) - 2 ? $this->wrapTable($segment) : $this->wrapValue($segment); })->implode('.'); diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index fe99ae5634..b21f78429a 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -122,7 +122,12 @@ class Builder implements BuilderContract /** * The table which the query is targeting. */ - public Expression|string|null $from = null; + public ExpressionContract|string|null $from = null; + + /** + * The logical alias explicitly supplied for the query source. + */ + protected ?string $fromAlias = null; /** * The index hint for the query. @@ -337,18 +342,24 @@ public function fromSub(Closure|self|EloquentBuilder|Relation|string $query, str { [$query, $bindings] = $this->createSub($query); - return $this->fromRaw('(' . $query . ') as ' . $this->grammar->wrapTable($as), $bindings); + $this->fromRaw('(' . $query . ') as ' . $this->grammar->wrapTable($as), $bindings); + + $this->fromAlias = $as; + + return $this; } /** * Add a raw "from" clause to the query. */ - public function fromRaw(Expression|string $expression, mixed $bindings = []): static + public function fromRaw(ExpressionContract|string $expression, mixed $bindings = []): static { - $this->from = $expression instanceof Expression + $this->from = $expression instanceof ExpressionContract ? $expression : new Expression($expression); + $this->fromAlias = null; + $this->addBinding($bindings, 'from'); return $this; @@ -432,13 +443,13 @@ public function addSelect(mixed $column): static foreach ($columns as $as => $column) { if (is_string($as) && $column instanceof ExpressionContract) { if (is_null($this->columns)) { - $this->select($this->from . '.*'); + $this->select($this->getDefaultSelectColumn()); } $this->selectExpression($column, $as); } elseif (is_string($as) && $this->isQueryable($column)) { if (is_null($this->columns)) { - $this->select($this->from . '.*'); + $this->select($this->getDefaultSelectColumn()); } $this->selectSub($column, $as); @@ -454,6 +465,16 @@ public function addSelect(mixed $column): static return $this; } + /** + * Get the wildcard selection for the query's primary source. + */ + public function getDefaultSelectColumn(): string + { + // Raw sources need an explicit alias or selection: an unqualified wildcard + // can introduce duplicate columns into a joined pagination count subquery. + return ($this->fromAlias ?? last(preg_split('/\s+as\s+/i', $this->from))) . '.*'; + } + /** * Add a vector-similarity selection to the query. * @@ -511,7 +532,13 @@ public function from(Closure|self|EloquentBuilder|Relation|ExpressionContract|st return $this->fromSub($table, $as); } - $this->from = $as ? "{$table} as {$as}" : $table; + if ($table instanceof ExpressionContract && $as !== null && $as !== '') { + $this->fromRaw($table->getValue($this->grammar) . ' as ' . $this->grammar->wrapTable($as)); + } else { + $this->from = $as !== null && $as !== '' ? "{$table} as {$as}" : $table; + } + + $this->fromAlias = $as !== '' ? $as : null; return $this; } @@ -549,7 +576,7 @@ public function ignoreIndex(string $index): static /** * Add a "join" clause to the query. */ - public function join(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ?string $operator = null, mixed $second = null, string $type = 'inner', bool $where = false): static + public function join(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, mixed $second = null, string $type = 'inner', bool $where = false): static { $join = $this->newJoinClause($this, $type, $table); @@ -593,7 +620,7 @@ public function joinWhere(ExpressionContract|string $table, Closure|ExpressionCo * * @throws InvalidArgumentException */ - public function joinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, mixed $second = null, string $type = 'inner', bool $where = false): static + public function joinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, mixed $second = null, string $type = 'inner', bool $where = false): static { [$query, $bindings] = $this->createSub($query); @@ -635,7 +662,7 @@ public function leftJoinLateral(Closure|self|EloquentBuilder|Relation|string $qu /** * Add a left join to the query. */ - public function leftJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + public function leftJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { return $this->join($table, $first, $operator, $second, 'left'); } @@ -653,7 +680,7 @@ public function leftJoinWhere(ExpressionContract|string $table, Closure|Expressi * * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $query */ - public function leftJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + public function leftJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { return $this->joinSub($query, $as, $first, $operator, $second, 'left'); } @@ -661,7 +688,7 @@ public function leftJoinSub(Closure|self|EloquentBuilder|Relation|string $query, /** * Add a right join to the query. */ - public function rightJoin(ExpressionContract|string $table, Closure|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + public function rightJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { return $this->join($table, $first, $operator, $second, 'right'); } @@ -679,7 +706,7 @@ public function rightJoinWhere(ExpressionContract|string $table, Closure|Express * * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|string $query */ - public function rightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + public function rightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { return $this->joinSub($query, $as, $first, $operator, $second, 'right'); } @@ -687,7 +714,7 @@ public function rightJoinSub(Closure|self|EloquentBuilder|Relation|string $query /** * Add a "cross join" clause to the query. */ - public function crossJoin(ExpressionContract|string $table, Closure|ExpressionContract|string|null $first = null, ?string $operator = null, ExpressionContract|string|null $second = null): static + public function crossJoin(ExpressionContract|string $table, Closure|ExpressionContract|string|null $first = null, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { if ($first) { return $this->join($table, $first, $operator, $second, 'cross'); @@ -717,7 +744,7 @@ public function crossJoinSub(Closure|self|EloquentBuilder|Relation|string $query /** * Add a straight join to the query. */ - public function straightJoin(ExpressionContract|string $table, Closure|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + public function straightJoin(ExpressionContract|string $table, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { return $this->join($table, $first, $operator, $second, 'straight_join'); } @@ -735,7 +762,7 @@ public function straightJoinWhere(ExpressionContract|string $table, Closure|Expr * * @param Closure|self|EloquentBuilder<*>|Relation<*, *, *>|string $query */ - public function straightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ?string $operator = null, ExpressionContract|string|null $second = null): static + public function straightJoinSub(Closure|self|EloquentBuilder|Relation|string $query, string $as, Closure|ExpressionContract|string $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { return $this->joinSub($query, $as, $first, $operator, $second, 'straight_join'); } @@ -840,7 +867,7 @@ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|a $type = 'Basic'; $columnString = ($column instanceof ExpressionContract) - ? $this->grammar->getValue($column) + ? (string) $this->grammar->getValue($column) : $column; // If the column is making a JSON reference we'll check to see if the value @@ -982,7 +1009,7 @@ public function orWhereNot(Closure|self|EloquentBuilder|Relation|ExpressionContr /** * Add a "where" clause comparing two columns to the query. */ - public function whereColumn(ExpressionContract|string|array $first, ?string $operator = null, ?string $second = null, string $boolean = 'and'): static + public function whereColumn(ExpressionContract|string|array $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null, string $boolean = 'and'): static { // If the column is an array, we will assume it is an array of key-value pairs // and can add them each as a where clause. We will maintain the boolean we @@ -1017,7 +1044,7 @@ public function whereColumn(ExpressionContract|string|array $first, ?string $ope /** * Add an "or where" clause comparing two columns to the query. */ - public function orWhereColumn(ExpressionContract|string|array $first, ?string $operator = null, ?string $second = null): static + public function orWhereColumn(ExpressionContract|string|array $first, ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null): static { return $this->whereColumn($first, $operator, $second, 'or'); } @@ -2912,7 +2939,7 @@ public function findOr(mixed $id, Closure|ExpressionContract|array|string $colum /** * Get a single column's value from the first result of a query. */ - public function value(string $column): mixed + public function value(ExpressionContract|string $column): mixed { return $this->withoutFetchUsing(function () use ($column) { $result = (array) $this->first([$column]); @@ -2939,7 +2966,7 @@ public function rawValue(string $expression, array $bindings = []): mixed * @throws \Hypervel\Database\RecordsNotFoundException * @throws \Hypervel\Database\MultipleRecordsFoundException */ - public function soleValue(string $column): mixed + public function soleValue(ExpressionContract|string $column): mixed { return $this->withoutFetchUsing(function () use ($column) { $result = (array) $this->sole([$column]); @@ -3168,7 +3195,7 @@ protected function runPaginationCountQuery(array $columns = ['*']): array $clone->timeout = null; if (is_null($clone->columns) && ! empty($this->joins)) { - $clone->select($this->from . '.*'); + $clone->select($clone->getDefaultSelectColumn()); } return $countQuery @@ -3278,7 +3305,11 @@ function () { // If the columns are qualified with a table or have an alias, we cannot use // those directly in the "pluck" operations since the results from the DB // are only keyed by the column itself. We'll strip the table out here. - $column = $this->stripTableForPluck($column); + // Databases choose different names for unaliased expressions. Use the + // returned field name instead of trying to infer it from the SQL. + $column = $column instanceof ExpressionContract + ? (string) array_key_first((array) $queryResult[0]) + : $this->stripTableForPluck($column); $key = $this->stripTableForPluck($key); @@ -3968,7 +3999,7 @@ protected function forSubQuery(): self public function getColumns(): array { return ! is_null($this->columns) - ? array_map(fn ($column) => $this->grammar->getValue($column), $this->columns) + ? array_map(fn ($column) => (string) $this->grammar->getValue($column), $this->columns) : []; } diff --git a/src/database/src/Query/JoinClause.php b/src/database/src/Query/JoinClause.php index 7e703bab0b..955c861204 100644 --- a/src/database/src/Query/JoinClause.php +++ b/src/database/src/Query/JoinClause.php @@ -78,7 +78,7 @@ public function __construct(Builder $parentQuery, string $type, ExpressionContra */ public function on( Closure|ExpressionContract|string $first, - ?string $operator = null, + ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null, string $boolean = 'and', ): static { @@ -94,7 +94,7 @@ public function on( */ public function orOn( Closure|ExpressionContract|string $first, - ?string $operator = null, + ExpressionContract|string|null $operator = null, ExpressionContract|string|null $second = null, ): static { return $this->on($first, $operator, $second, 'or'); diff --git a/src/database/src/Schema/BlueprintState.php b/src/database/src/Schema/BlueprintState.php index deac897c0f..09d76990d7 100644 --- a/src/database/src/Schema/BlueprintState.php +++ b/src/database/src/Schema/BlueprintState.php @@ -4,6 +4,7 @@ namespace Hypervel\Database\Schema; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\Connection; use Hypervel\Database\Query\Expression; use Hypervel\Support\Collection; @@ -302,13 +303,13 @@ public function update(Fluent $command): void /** * Replace an exact column name in a projection. * - * @param list $columns - * @return list + * @param list $columns + * @return list */ protected function replaceColumn(array $columns, string $from, string $to): array { return array_map( - static fn (Expression|string $column): Expression|string => $column === $from ? $to : $column, + static fn (ExpressionContract|string $column): ExpressionContract|string => $column === $from ? $to : $column, $columns, ); } diff --git a/src/facade-documenter/facade.php b/src/facade-documenter/facade.php index 92321434f9..c0c359062c 100755 --- a/src/facade-documenter/facade.php +++ b/src/facade-documenter/facade.php @@ -41,6 +41,7 @@ use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\ConstExprParser; @@ -548,7 +549,21 @@ function resolveDocblockTypes($method, $typeNode, int $depth = 1) } $genericArgs = collect($typeNode->genericTypes) - ->map(fn ($node) => resolveDocblockTypes($method, $node, $depth + 1)) + ->map(function (TypeNode $node, int $index) use ($method, $typeNode, $depth): ?string { + $variance = $typeNode->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT; + + // Match GenericTypeNode's rendering: '*' is stored as mixed with + // bivariant metadata, but rendering it as mixed changes the type. + if ($variance === GenericTypeNode::VARIANCE_BIVARIANT) { + return '*'; + } + + $type = resolveDocblockTypes($method, $node, $depth + 1); + + return $variance === GenericTypeNode::VARIANCE_INVARIANT + ? $type + : $variance . ' ' . $type; + }) ->filter(); // Use all() === [] instead of isEmpty(); Hypervel's Collection::isEmpty() diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index 96713e1b1e..71209fb1d4 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Events\QueryExecuted; @@ -15,6 +16,7 @@ use Hypervel\Testing\Constraints\NotSoftDeletedInDatabase; use Hypervel\Testing\Constraints\SoftDeletedInDatabase; use PHPUnit\Framework\Constraint\LogicalNot as ReverseConstraint; +use UnitEnum; trait InteractsWithDatabase { @@ -306,12 +308,8 @@ protected function isSoftDeletableModel($model) /** * Cast a JSON string to a database compatible type. - * - * @param array|object|string $value - * @param null|string $connection - * @return \Hypervel\Database\Query\Expression */ - public function castAsJson($value, $connection = null) + public function castAsJson(array|object|string $value, UnitEnum|string|null $connection = null): Expression { if ($value instanceof Jsonable) { $value = $value->toJson(); diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index ba619194d5..7f13ea3e18 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -87,7 +87,7 @@ * @method static array[] pretend(\Closure $callback) * @method static bool pretending() * @method static \Hypervel\Database\Query\Builder query() - * @method static \Hypervel\Database\Query\Expression raw(mixed $value) + * @method static \Hypervel\Contracts\Database\Query\Expression raw(mixed $value) * @method static void reconnectIfMissingConnection() * @method static void recordsHaveBeenModified(bool $value = true) * @method static void resetForPool() @@ -111,7 +111,7 @@ * @method static \Hypervel\Database\PdoConnection setTablePrefix(string $prefix) * @method static \Hypervel\Database\PdoConnection setTransactionManager(\Hypervel\Database\DatabaseTransactionsManager $manager) * @method static bool statement(string $query, array $bindings = []) - * @method static \Hypervel\Database\Query\Builder table(\Closure|\Hypervel\Database\Query\Builder|\UnitEnum|string $table, string|null $as = null) + * @method static \Hypervel\Database\Query\Builder table(\Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|\UnitEnum|string $table, string|null $as = null) * @method static int|null threadCount() * @method static float totalQueryDuration() * @method static mixed transaction(\Closure $callback, int $attempts = 1) diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index e7a327e2e3..ae715f1111 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -374,10 +374,10 @@ public function testGetMethodDoesntHydrateEagerRelationsWhenNoResultsAreReturned $this->assertEquals([], $results->all()); } - public function testValueMethodWithModelFound() + public function testValueMethodWithModelFound(): void { $builder = m::mock(Builder::class . '[first]', [$this->getMockQueryBuilder()]); - $mockModel = new stdClass; + $mockModel = new class extends Model {}; $mockModel->name = 'foo'; $builder->shouldReceive('first')->with(['name'])->andReturn($mockModel); diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 9f8721dd93..8307b55a6a 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -4476,6 +4476,19 @@ public function testHasAttribute() $this->assertFalse($user->hasAttribute('belongsToStub')); } + public function testZeroNamedAttributesUseNormalAttributeAccess(): void + { + $model = new ModelStub; + $model->setAttribute('0', '42'); + $model->mergeCasts(['0' => 'integer']); + + $this->assertTrue($model->hasAttribute('0')); + $this->assertSame(42, $model->getAttribute('0')); + $this->assertSame(42, $model->{'0'}); + $this->assertFalse($model->hasAttribute('')); + $this->assertNull($model->getAttribute('')); + } + public function testModelToJsonSucceedsWithPriorErrors(): void { $user = new ModelStub(['name' => 'Mateus']); diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 14a98b6415..5b35485264 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -10,10 +10,12 @@ use DatePeriod; use DateTime; use Hypervel\Contracts\Database\Query\ConditionExpression; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Grammar as BaseGrammar; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Expression as Raw; use Hypervel\Database\Query\Grammars\Grammar; @@ -159,6 +161,90 @@ public function testBasicSelectWithPrefix() $this->assertSame('select * from "prefix_users"', $builder->toSql()); } + public function testDefaultSelectionUsesTheLogicalSourceAlias(): void + { + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->from('users', '0')->addSelect(['bonus' => new Raw(42)]); + + $this->assertSame('select "prefix_0".*, (42) as "bonus" from "prefix_users" as "prefix_0"', $builder->toSql()); + + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->from('users AS people')->addSelect(['bonus' => new Raw(42)]); + + $this->assertSame('select "prefix_people".*, (42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); + } + + public function testReplacingTheSourceResetsItsDefaultSelectionAlias(): void + { + $builder = $this->getBuilder(prefix: 'prefix_')->fromSub('select 1 as id', 'old'); + + $plain = (clone $builder)->from('users')->addSelect(['bonus' => new Raw(42)]); + $this->assertSame('select "prefix_users".*, (42) as "bonus" from "prefix_users"', $plain->toSql()); + + $subquery = (clone $builder)->fromSub('select 2 as id', 'new')->addSelect(['bonus' => new Raw(42)]); + $this->assertSame('select "prefix_new".*, (42) as "bonus" from (select 2 as id) as "prefix_new"', $subquery->toSql()); + + $this->expectException(TypeError::class); + + $builder->fromRaw('users')->addSelect(['bonus' => new Raw(42)]); + } + + public function testContractExpressionsAreAcceptedAsQuerySources(): void + { + $expression = new class implements ExpressionContract { + public function getValue(BaseGrammar $grammar): string + { + return $grammar->wrapTable('users'); + } + }; + + $builder = $this->getBuilder(prefix: 'prefix_'); + $builder->fromRaw($expression); + $this->assertSame('select * from "prefix_users"', $builder->toSql()); + $this->assertSame($expression, $builder->from); + + $builder->from($expression, 'people')->addSelect(['bonus' => new Raw(42)]); + $this->assertSame('select "prefix_people".*, (42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); + + $builder->from($expression); + $this->assertSame($expression, $builder->from); + } + + public function testNumericExpressionsRemainSqlAndColumnsAreText(): void + { + $builder = $this->getBuilder(); + $builder->from('users')->select([new Raw(0), new Raw(1.5), 'name']) + ->where(new Raw(1), 1)->orWhere(new Raw(1.5), 1.5); + + $this->assertSame('select 0, 1.5, "name" from "users" where 1 = ? or 1.5 = ?', $builder->toSql()); + $this->assertSame([1, 1.5], $builder->getBindings()); + $this->assertSame(['0', '1.5', 'name'], $builder->getColumns()); + } + + public function testColumnAndJoinShortcutsAcceptContractExpressions(): void + { + $expression = new class implements ExpressionContract { + public function getValue(BaseGrammar $grammar): string + { + return $grammar->wrap('users.id'); + } + }; + + $builder = $this->getBuilder()->from('users') + ->whereColumn('id', $expression)->orWhereColumn('id', '=', $expression) + ->leftJoin('contacts', 'contacts.user_id', $expression) + ->rightJoin('accounts', $expression, '=', 'accounts.user_id') + ->join('profiles', function (JoinClause $join) use ($expression): void { + $join->on('profiles.user_id', $expression)->orOn('profiles.owner_id', $expression); + }); + + $this->assertSame( + 'select * from "users" left join "contacts" on "contacts"."user_id" = "users"."id" right join "accounts" on "users"."id" = "accounts"."user_id" inner join "profiles" on "profiles"."user_id" = "users"."id" or "profiles"."owner_id" = "users"."id" where "id" = "users"."id" or "id" = "users"."id"', + $builder->toSql(), + ); + $this->assertSame([], $builder->getBindings()); + } + public function testBasicSelectDistinct() { $builder = $this->getBuilder(); diff --git a/tests/Database/DatabaseQueryGrammarTest.php b/tests/Database/DatabaseQueryGrammarTest.php index 388af0f827..1ed4ca14d0 100644 --- a/tests/Database/DatabaseQueryGrammarTest.php +++ b/tests/Database/DatabaseQueryGrammarTest.php @@ -38,6 +38,18 @@ public function testWrapIdentifierEscapesOneIdentifierWithoutApplyingTheTablePre $this->assertSame('`a``b`', (new MySqlGrammar($connection))->wrapIdentifier('a`b')); } + public function testQualifiedColumnsPrefixOnlyTheTableSegment(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn('app_'); + $grammar = new Grammar($connection); + + $this->assertSame('"id"', $grammar->wrap('id')); + $this->assertSame('"app_users"."id"', $grammar->wrap('users.id')); + $this->assertSame('"main"."app_users"."id"', $grammar->wrap('main.users.id')); + $this->assertSame('"main"."app_users".*', $grammar->wrap('main.users.*')); + } + public function testWhereRawReturnsStringWhenExpressionPassed(): void { $builder = m::mock(Builder::class); diff --git a/tests/FacadeDocumenter/GenericPreservationTest.php b/tests/FacadeDocumenter/GenericPreservationTest.php index c572f3aef0..ffa87c50c1 100644 --- a/tests/FacadeDocumenter/GenericPreservationTest.php +++ b/tests/FacadeDocumenter/GenericPreservationTest.php @@ -158,9 +158,84 @@ class Facade $this->assertStringContainsString('@method static void accept(array $parameters)', $contents); } - /** - * Preserve balanced union-typed template bounds. - */ + public function testGenericArgumentVarianceIsPreserved(): void + { + $this->writeAppFile( + 'Generic/Variance/Proxy.php', + <<<'PHP' + $query + * @return Builder<*> + */ + public function query(Builder $query): Builder + { + return $query; + } + + /** @param array> $queries */ + public function nested(array $queries): void + { + } + + /** @param Builder $query */ + public function covariant(Builder $query): void + { + } + + /** @param Builder $query */ + public function contravariant(Builder $query): void + { + } + + /** @param Collection $values */ + public function invariant(Collection $values): void + { + } + } + PHP + ); + + $this->writeAppFile( + 'Generic/Variance/Facade.php', + <<<'PHP' + runDocumenter(['App\Generic\Variance\Facade']); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput() . $process->getOutput()); + + $contents = $this->appFileContents('App\Generic\Variance\Facade'); + + $this->assertStringContainsString('@method static \Hypervel\Database\Eloquent\Builder<*> query(\Hypervel\Database\Eloquent\Builder<*> $query)', $contents); + $this->assertStringContainsString('@method static void nested(array> $queries)', $contents); + $this->assertStringContainsString('@method static void covariant(\Hypervel\Database\Eloquent\Builder $query)', $contents); + $this->assertStringContainsString('@method static void contravariant(\Hypervel\Database\Eloquent\Builder $query)', $contents); + $this->assertStringContainsString('@method static void invariant(\Hypervel\Support\Collection $values)', $contents); + } + public function testUnionTypedTemplateBoundRemainsBalanced(): void { $this->writeAppFile( diff --git a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php index 4c2ed85bcf..6f043cbc51 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php @@ -93,6 +93,137 @@ public function testCastAsJsonUsesSpecifiedConnection(): void } } + public function testCastToJsonSqlite(): void + { + $grammar = 'SQLite'; + + $this->assertEquals( + <<<'TEXT' + '["foo","bar"]' + TEXT, + $this->castAsJsonUsingGrammar(['foo', 'bar'], $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + '["foo","bar"]' + TEXT, + $this->castAsJsonUsingGrammar(collect(['foo', 'bar']), $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + '{"foo":"bar"}' + TEXT, + $this->castAsJsonUsingGrammar((object) ['foo' => 'bar'], $grammar) + ); + } + + public function testCastToJsonPostgres(): void + { + $grammar = 'Postgres'; + + $this->assertEquals( + <<<'TEXT' + '["foo","bar"]' + TEXT, + $this->castAsJsonUsingGrammar(['foo', 'bar'], $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + '["foo","bar"]' + TEXT, + $this->castAsJsonUsingGrammar(collect(['foo', 'bar']), $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + '{"foo":"bar"}' + TEXT, + $this->castAsJsonUsingGrammar((object) ['foo' => 'bar'], $grammar) + ); + } + + public function testCastToJsonMySql(): void + { + $grammar = 'MySql'; + + $this->assertEquals( + <<<'TEXT' + cast('["foo","bar"]' as json) + TEXT, + $this->castAsJsonUsingGrammar(['foo', 'bar'], $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + cast('["foo","bar"]' as json) + TEXT, + $this->castAsJsonUsingGrammar(collect(['foo', 'bar']), $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + cast('{"foo":"bar"}' as json) + TEXT, + $this->castAsJsonUsingGrammar((object) ['foo' => 'bar'], $grammar) + ); + } + + public function testCastToJsonMariaDb(): void + { + $grammar = 'MariaDb'; + + $this->assertEquals( + <<<'TEXT' + json_query('["foo","bar"]', '$') + TEXT, + $this->castAsJsonUsingGrammar(['foo', 'bar'], $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + json_query('["foo","bar"]', '$') + TEXT, + $this->castAsJsonUsingGrammar(collect(['foo', 'bar']), $grammar) + ); + + $this->assertEquals( + <<<'TEXT' + json_query('{"foo":"bar"}', '$') + TEXT, + $this->castAsJsonUsingGrammar((object) ['foo' => 'bar'], $grammar) + ); + } + + /** + * Cast JSON using the given grammar and the driver-neutral escape boundary. + */ + protected function castAsJsonUsingGrammar(array|object|string $value, string $grammar): string + { + $database = DB::getFacadeRoot(); + $connection = m::mock(Connection::class); + $grammarClass = 'Hypervel\Database\Query\Grammars\\' . $grammar . 'Grammar'; + $grammar = new $grammarClass($connection); + + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + $connection->shouldReceive('raw')->andReturnUsing( + static fn (string $value): Expression => new Expression($value), + ); + $connection->shouldReceive('escape')->andReturnUsing( + static fn (string $value): string => "'{$value}'", + ); + + try { + DB::shouldReceive('connection')->with(null)->andReturn($connection); + + return $this->castAsJson($value)->getValue($grammar); + } finally { + DB::swap($database); + } + } + public function testAssertModelExists() { $user = User::factory()->create(); diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index 4ddec21e41..144ad4f0d0 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -7,6 +7,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\HasManyThrough; +use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; use Hypervel\Pagination\Cursor; use Hypervel\Support\Facades\DB; @@ -54,6 +55,17 @@ public function testCursorPaginationOnTopOfColumns() $this->assertCount(15, TestPost::cursorPaginate(15, ['id', 'title'])); } + public function testCursorPaginationWithNumericExpressionProjection(): void + { + TestPost::fillAndInsert([['title' => 'First'], ['title' => 'Second']]); + + $page = TestPost::query()->select([new Expression(1.5), 'id']) + ->orderBy('id')->cursorPaginate(1, cursor: new Cursor(['id' => 1])); + + $this->assertSame([2], $page->getCollection()->modelKeys()); + $this->assertEquals(1.5, array_first($page->items()[0]->getAttributes())); + } + public function testPaginationWithUnion() { TestPost::fillAndInsert([ diff --git a/tests/Integration/Database/EloquentWhereTest.php b/tests/Integration/Database/EloquentWhereTest.php index e10bc3f894..b485c1f799 100644 --- a/tests/Integration/Database/EloquentWhereTest.php +++ b/tests/Integration/Database/EloquentWhereTest.php @@ -8,6 +8,7 @@ use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\MultipleRecordsFoundException; use Hypervel\Database\Query\Builder; +use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -306,6 +307,33 @@ public function testSoleValue() $this->assertEquals('test-name', UserWhereTest::where('name', 'test-name')->soleValue('name')); } + public function testExpressionValuesPreserveSqlAndModelAttributeAccess(): void + { + UserWhereTest::create(['name' => 'Taylor', 'email' => 'taylor@example.com', 'address' => 'Main Street']); + + foreach ([0, 1.5, 'id + 1', 'id + 1 as total'] as $index => $value) { + $expression = new Expression($value); + $expected = [0, 1.5, 2, 2][$index]; + + $this->assertEquals($expected, UserWhereTest::query()->value($expression)); + $this->assertEquals($expected, UserWhereTest::query()->soleValue($expression)); + $this->assertEquals($expected, UserWhereTest::query()->valueOrFail($expression)); + $this->assertEquals([$expected], UserWhereTest::query()->pluck($expression)->all()); + } + + $this->assertSame(2.0, UserWhereTest::query()->withCasts(['total' => 'float'])->value(new Expression('id + 1 as total'))); + $this->assertSame('Taylor', UserWhereTest::query()->select('name')->value(new Expression(1))); + + $model = new class extends UserWhereTest { + public function getAttribute(string $key): mixed + { + return $key === 'total' ? 'Total: ' . parent::getAttribute($key) : parent::getAttribute($key); + } + }; + + $this->assertSame('Total: 2', $model->newQuery()->value(new Expression('id + 1 as total'))); + } + public function testChunkMap() { UserWhereTest::create([ diff --git a/tests/Integration/Database/EloquentWithCountTest.php b/tests/Integration/Database/EloquentWithCountTest.php index 1be3082923..4f2be62651 100644 --- a/tests/Integration/Database/EloquentWithCountTest.php +++ b/tests/Integration/Database/EloquentWithCountTest.php @@ -50,6 +50,22 @@ public function testItBasic() ], $results->get()->toArray()); } + public function testWithCountPreservesASubquerySourceAndItsBindings(): void + { + $one = Model1::create(); + Model1::create(); + $one->twos()->create(); + + $results = Model1::query() + ->fromSub(Model1::query()->whereKey($one->id), 'one') + ->withCount('twos') + ->get(); + + $this->assertEquals([ + ['id' => $one->id, 'twos_count' => 1], + ], $results->toArray()); + } + public function testGlobalScopes() { $one = Model1::create(); diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index 4d9f55da2e..d050ae222f 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Integration\Database; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Pagination\LengthAwarePaginator; use Hypervel\Database\MultipleRecordsFoundException; +use Hypervel\Database\Query\Expression; use Hypervel\Database\RecordsNotFoundException; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\CarbonImmutable; @@ -265,6 +267,46 @@ public function testFromWithAlias() $this->assertCount(2, DB::table('posts', 'alias')->select('alias.*')->get()); } + #[DefineEnvironment('definePrefixedEnvironment')] + public function testAliasedSourcesPreserveTheirColumnsWhenAddingSelections(): void + { + $expected = DB::table('posts')->orderBy('id')->get()->all(); + + foreach ([DB::table('posts', 'source'), DB::table('posts AS source'), DB::table('posts', '0')] as $query) { + $rows = $query->addSelect(['bonus' => new Expression(42)])->orderBy('id')->get(); + + $this->assertCount(2, $rows); + foreach ($rows as $index => $row) { + $this->assertEquals([...((array) $expected[$index]), 'bonus' => 42], (array) $row); + } + } + + $query = DB::query()->fromSub(DB::table('posts')->select('id')->where('id', '>', 0), 'source'); + + $this->assertEquals([ + (object) ['id' => 1, 'bonus' => 42], + (object) ['id' => 2, 'bonus' => 42], + ], (clone $query)->addSelect(['bonus' => new Expression(42)])->orderBy('id')->get()->all()); + + $this->assertEquals([ + (object) ['id' => 1, 'bonus' => 7], + (object) ['id' => 2, 'bonus' => 7], + ], $query->addSelect(['bonus' => DB::query()->selectRaw('?', [7])])->orderBy('id')->get()->all()); + } + + #[DefineEnvironment('definePrefixedEnvironment')] + public function testGroupedSubqueryPaginationPreservesItsSourceAndBindings(): void + { + $query = DB::query() + ->fromSub(DB::table('posts')->select('id')->where('id', '>', 0), 'source') + ->join('posts as joined', 'joined.id', '=', 'source.id') + ->groupBy('source.id'); + + $this->assertSame(2, $query->getCountForPagination()); + $this->assertNull($query->columns); + $this->assertSame([0], $query->getBindings()); + } + public function testFromWithSubQuery() { $this->assertSame( @@ -629,6 +671,18 @@ public function testChunkMap() $this->assertCount(3, DB::getQueryLog()); } + public function testScalarExpressionsUseTheReturnedFieldName(): void + { + foreach ([new Expression(0), new Expression(1.5), new Expression('id + 1'), new Expression('id + 1 as total')] as $expression) { + $query = DB::table('posts')->where('id', 1); + $expected = array_first((array) (clone $query)->first([$expression])); + + $this->assertSame($expected, (clone $query)->value($expression)); + $this->assertSame($expected, (clone $query)->soleValue($expression)); + $this->assertSame([1 => $expected], (clone $query)->pluck($expression, 'id')->all()); + } + } + public function testPluck() { // Test SELECT override, since pluck will take the first column. @@ -864,6 +918,16 @@ public function testFailedSelectRestoresOriginalColumns(): void $this->assertNull($query->columns); } + /** + * Configure a table prefix before the connection and schema are created. + */ + protected function definePrefixedEnvironment(ApplicationContract $app): void + { + $config = $app->make('config'); + $connection = $config->string('database.default'); + $config->set("database.connections.{$connection}.prefix", 'app_'); + } + protected function defineEnvironmentWouldThrowsPDOException($app): void { $this->afterApplicationCreated(function () { diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php index 09946fe669..90bf3a69ff 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php @@ -6,7 +6,9 @@ use Closure; use Exception; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\Grammar; use Hypervel\Database\QueryException; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\SQLiteConnection; @@ -15,6 +17,7 @@ use Hypervel\Testbench\Attributes\RequiresDatabase; use Override; use PDO; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class DatabaseSchemaBlueprintTest extends SqliteTestCase @@ -1197,7 +1200,8 @@ public function testNativeDropAllowsAnUnrelatedRichIndexInEitherCommandOrder(): } } - public function testNewRawIndexBeforeRenameFailsWithoutATypeError(): void + #[DataProvider('rawIndexExpressionProvider')] + public function testNewRawIndexBeforeRenameFailsWithoutATypeError(bool $useContractExpression): void { $connection = DB::connection(); $schema = $connection->getSchemaBuilder(); @@ -1208,8 +1212,17 @@ public function testNewRawIndexBeforeRenameFailsWithoutATypeError(): void }); try { - $schema->table('items', function (Blueprint $table) { - $table->rawIndex('lower("email")', 'email_expression'); + $schema->table('items', function (Blueprint $table) use ($useContractExpression): void { + if ($useContractExpression) { + $table->index([new class implements Expression { + public function getValue(Grammar $grammar): string + { + return 'lower("email")'; + } + }], 'email_expression'); + } else { + $table->rawIndex('lower("email")', 'email_expression'); + } $table->renameColumn('name', 'label'); $table->bigInteger('score')->change(); }); @@ -1222,6 +1235,14 @@ public function testNewRawIndexBeforeRenameFailsWithoutATypeError(): void $this->assertNull($this->indexSql('email_expression')); } + /** + * Provide concrete and contract-only index expression paths. + */ + public static function rawIndexExpressionProvider(): array + { + return ['concrete' => [false], 'contract' => [true]]; + } + public function testAddUniqueIndexWithoutNameWorks() { DB::connection()->getSchemaBuilder()->create('users', function ($table) { diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php index 212e73ed22..7e8a44a219 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php @@ -94,6 +94,30 @@ public function testHasColumnAndIndexWithPrefixIndexEnabled(): void $this->assertContains('example_table1_name_index', $indexes); } + public function testSchemaQualifiedPrefixedTablesPreserveQueryIdentifiers(): void + { + $connection = DB::connection('sqlite-with-indexed-prefix'); + $connection->getSchemaBuilder()->create('items', function (Blueprint $table): void { + $table->integer('id'); + }); + $connection->table('items')->insert([['id' => 1], ['id' => 2]]); + + $this->assertSame([ + ['id' => 1, 'bonus' => 42], + ['id' => 2, 'bonus' => 42], + ], $connection->table('main.items', 'source')->addSelect(['bonus' => new Expression(42)]) + ->orderBy('id')->get()->map(static fn (object $row): array => (array) $row)->all()); + + $query = $connection->table('main.items', 'source') + ->join('items as joined', 'joined.id', '=', 'source.id') + ->groupBy('source.id'); + + $this->assertSame(2, $query->getCountForPagination()); + $this->assertNull($query->columns); + $this->assertSame(1, $connection->table('main.items')->delete(1)); + $this->assertSame([2], $connection->table('items')->pluck('id')->all()); + } + public function testAlterTableAddForeignKeyWithPrefix(): void { $schema = Schema::connection('sqlite-with-prefix'); diff --git a/types/Database/Query/Builder.php b/types/Database/Query/Builder.php index 4f0fa267b9..d4205fdec9 100644 --- a/types/Database/Query/Builder.php +++ b/types/Database/Query/Builder.php @@ -4,6 +4,8 @@ namespace Hypervel\Types\Query\Builder; +use Hypervel\Contracts\Database\Query\Expression; +use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Query\Builder; use PDO; @@ -82,6 +84,30 @@ function test(Builder $query, EloquentBuilder $userQuery): void assertType('5', $query->pipe(fn ($query) => 5)); } +/** + * Verify contract-only expressions across connection and query forwarding. + */ +function testExpressionContracts(Builder $query, ConnectionInterface $connection, Expression $expression): void +{ + assertType('Hypervel\Contracts\Database\Query\Expression', $connection->raw(1)); + assertType('Hypervel\Database\Query\Builder', $connection->table($expression)); + assertType('Hypervel\Database\Query\Builder', $query->fromRaw($expression)); + assertType('mixed', $query->value($expression)); + assertType('mixed', $query->soleValue($expression)); + assertType('list', $query->select([$expression])->getColumns()); + + $query->whereColumn('id', $expression)->orWhereColumn('id', '=', $expression); + $query->join('users', $expression, $expression) + ->leftJoin('users', $expression, $expression) + ->rightJoin('users', $expression, $expression) + ->crossJoin('users', $expression, $expression) + ->straightJoin('users', $expression, $expression); + $query->joinSub($query->newQuery(), 'source', $expression, $expression) + ->leftJoinSub($query->newQuery(), 'source', $expression, $expression) + ->rightJoinSub($query->newQuery(), 'source', $expression, $expression) + ->straightJoinSub($query->newQuery(), 'source', $expression, $expression); +} + /** @param \Hypervel\Database\Eloquent\Builder $userQuery */ function testStatementEloquentFetchUsing(EloquentBuilder $userQuery): void { From ff76fba307499f885f01997fe623417020bb44b9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:01:19 +0000 Subject: [PATCH 04/23] Port Laravel queue, helper, context and full-text updates Complete the applicable changes from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: - https://github.com/laravel/framework/pull/57881 - https://github.com/laravel/framework/pull/57924 - https://github.com/laravel/framework/pull/31507 - https://github.com/laravel/framework/pull/57915 - https://github.com/laravel/framework/pull/58012 Require serializable-closure ^2.0.11 in the root and queue package. Its wrapper-preservation fix resolves the actual chained-closure restoration failure before displayName runs, so keep the typed implementation without Laravel's raw-Closure compatibility branch. Other split packages inherit the required floor through queue where needed. Keep the installed current serializer version; do not introduce a version check or fallback. Restore the public SerializableClosure property exposed by Laravel's CallQueuedClosure. Extend the existing batch matcher to check original closure identity through that property. Add real completion coverage for a closure following two consecutive batches, reusing the existing fixtures and worker runner. Batch callback options round-trip through the database repository with both database and sync queue connections. Simplify preg_replace_array's callback to return array_shift directly and apply native parameter and callback return types. Port every current upstream test case, including empty, sparse, associative, falsy and advanced array-pointer inputs, into the established focused helper-test layout. The cleanup preserves behavior rather than changing replacement semantics. Document PostgreSQL full-text mode options and a valid raw query example. The grammar and upstream compilation assertions were already present; add execution coverage for raw operators and prefix matching against the existing PostgreSQL article fixtures. Port the context scope type fixture, retaining the integer-range and null assertions, and cover fluent hydration/dehydration callbacks. Correct their return annotations to mixed while retaining the base Repository argument, worker-global listener registration and Laravel's false-return propagation. No new state, extra queries, serialization passes or compatibility machinery. Validation: changed test files pass immediately; database-backed queue chaining and the new sync case pass; raw full-text runs against isolated PostgreSQL; the Log ParaTest suite, full source and type-fixture PHPStan, formatting and diff checks pass. Serializer minimum-version behavior was independently verified; framework checks use installed v2.0.16. --- composer.json | 2 +- src/docs/queries.md | 8 ++++ src/log/src/Context/Repository.php | 4 +- src/queue/composer.json | 2 +- src/queue/src/CallQueuedClosure.php | 2 +- src/support/src/helpers.php | 13 ++--- tests/Bus/BusBatchTest.php | 9 ++-- .../Database/Postgres/FulltextTest.php | 10 ++++ tests/Integration/Queue/JobChainingTest.php | 15 ++++++ tests/Support/PregReplaceArrayTest.php | 48 +++++++++++++++++++ types/Log/Context.php | 20 ++++++++ 11 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 tests/Support/PregReplaceArrayTest.php create mode 100644 types/Log/Context.php diff --git a/composer.json b/composer.json index 44f4cae62f..717d8d1ee8 100644 --- a/composer.json +++ b/composer.json @@ -171,7 +171,7 @@ "guzzlehttp/psr7": "^2.13", "guzzlehttp/uri-template": "^1.0", "hypervel/laminas-mime": "^0.1.0", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "lcobucci/jwt": "^5.0", "league/commonmark": "^2.10", "league/flysystem": "^3.25.1", diff --git a/src/docs/queries.md b/src/docs/queries.md index 8a8813cd9a..d286f1b2ba 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -1272,6 +1272,14 @@ $users = DB::table('users') ->get(); ``` +On PostgreSQL, the `mode` option accepts `plain` (the default), `phrase`, `websearch`, or `raw`. The `raw` mode allows you to use [PostgreSQL text search query syntax](https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES): + +```php +$users = DB::table('users') + ->whereFullText('bio', 'web & developer', ['mode' => 'raw']) + ->get(); +``` + ### Vector Similarity Clauses diff --git a/src/log/src/Context/Repository.php b/src/log/src/Context/Repository.php index 09793e04d5..f203fa0534 100644 --- a/src/log/src/Context/Repository.php +++ b/src/log/src/Context/Repository.php @@ -551,7 +551,7 @@ public function replicate(): static * Boot-only. Registers a listener on the worker-global event dispatcher; * per-request registration persists and affects subsequent requests. * - * @param (callable(self): void) $callback + * @param (callable(self): mixed) $callback * @return $this */ public function dehydrating(callable $callback): static @@ -567,7 +567,7 @@ public function dehydrating(callable $callback): static * Boot-only. Registers a listener on the worker-global event dispatcher; * per-request registration persists and affects subsequent requests. * - * @param (callable(self): void) $callback + * @param (callable(self): mixed) $callback * @return $this */ public function hydrated(callable $callback): static diff --git a/src/queue/composer.json b/src/queue/composer.json index 6df1d3c5a5..675afeee16 100644 --- a/src/queue/composer.json +++ b/src/queue/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": "^8.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "nesbot/carbon": "^3.13.1", "nunomaduro/termwind": "^2.0", "symfony/console": "^8.1", diff --git a/src/queue/src/CallQueuedClosure.php b/src/queue/src/CallQueuedClosure.php index 026e2c0344..579e77fcc6 100644 --- a/src/queue/src/CallQueuedClosure.php +++ b/src/queue/src/CallQueuedClosure.php @@ -41,7 +41,7 @@ class CallQueuedClosure implements ShouldQueue * Create a new job instance. */ public function __construct( - protected SerializableClosure $closure + public SerializableClosure $closure ) { } diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index 44d013c3ac..75da7489f5 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -291,17 +291,12 @@ function optional($value = null, ?callable $callback = null) if (! function_exists('preg_replace_array')) { /** - * Replace a given pattern with each value in the array in sequentially. - * - * @param string $pattern - * @param string $subject + * Replace a given pattern with each value in the array sequentially. */ - function preg_replace_array($pattern, array $replacements, $subject): string + function preg_replace_array(string $pattern, array $replacements, string $subject): string { - return preg_replace_callback($pattern, function () use (&$replacements) { - foreach ($replacements as $value) { - return array_shift($replacements); - } + return preg_replace_callback($pattern, function () use (&$replacements): mixed { + return array_shift($replacements); }, $subject); } } diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php index af9a501545..e30ba94423 100644 --- a/tests/Bus/BusBatchTest.php +++ b/tests/Bus/BusBatchTest.php @@ -123,18 +123,19 @@ public function testJobsCanBeAddedToTheBatch(): void use Batchable; }; - $thirdJob = function () { + $thirdJob = function (): void { }; $queue->shouldReceive('connection')->once() ->with('test-connection') ->andReturn($connection = m::mock(QueueContract::class)); - $connection->shouldReceive('bulk')->once()->with(m::on(function ($args) use ($job, $secondJob) { + $connection->shouldReceive('bulk')->once()->with(m::on(function (array $args) use ($job, $secondJob, $thirdJob): bool { return - $args[0] == $job - && $args[1] == $secondJob + $args[0] === $job + && $args[1] === $secondJob && $args[2] instanceof CallQueuedClosure + && $args[2]->closure->getClosure() === $thirdJob && is_string($args[2]->batchId); }), '', 'test-queue'); diff --git a/tests/Integration/Database/Postgres/FulltextTest.php b/tests/Integration/Database/Postgres/FulltextTest.php index cd22cdd94b..a429dc9d42 100644 --- a/tests/Integration/Database/Postgres/FulltextTest.php +++ b/tests/Integration/Database/Postgres/FulltextTest.php @@ -69,4 +69,14 @@ public function testWhereFulltextWithPhrase() $this->assertCount(1, $articles); } + + public function testWhereFulltextWithRaw(): void + { + $articles = DB::table('articles') + ->whereFullText(['title', 'body'], 'PostgreSQL & tut:*', ['mode' => 'raw']) + ->orderBy('id') + ->get(); + + $this->assertSame(['PostgreSQL Tutorial', 'Optimizing PostgreSQL'], $articles->pluck('title')->all()); + } } diff --git a/tests/Integration/Queue/JobChainingTest.php b/tests/Integration/Queue/JobChainingTest.php index ba6b034404..3b50b6cfda 100644 --- a/tests/Integration/Queue/JobChainingTest.php +++ b/tests/Integration/Queue/JobChainingTest.php @@ -507,6 +507,21 @@ public function testBatchCanBeAddedToChain() $this->assertEquals(['c1', 'c2', 'b1', 'b2', 'b3', 'b4', 'c3'], JobRunRecorder::$results); } + public function testClosureCanCompleteAChainAfterMultipleBatches(): void + { + Bus::chain([ + Bus::batch([new JobChainingTestBatchedJob('b1')]), + Bus::batch([new JobChainingTestBatchedJob('b2')]), + static function (): void { + JobRunRecorder::record('c1'); + }, + ])->dispatch(); + + $this->runQueueWorkerCommand(['--stop-when-empty' => true]); + + $this->assertSame(['b1', 'b2', 'c1'], JobRunRecorder::$results); + } + public function testBatchInChainUsesCorrectQueue() { $otherQueue = $this->getQueueDriver() === 'redis' ? '{other}' : 'other'; diff --git a/tests/Support/PregReplaceArrayTest.php b/tests/Support/PregReplaceArrayTest.php new file mode 100644 index 0000000000..ee51b0a888 --- /dev/null +++ b/tests/Support/PregReplaceArrayTest.php @@ -0,0 +1,48 @@ +assertSame( + $expectedOutput, + preg_replace_array($pattern, $replacements, $subject) + ); + } + + /** + * Provide sequential replacement cases. + */ + public static function providesPregReplaceArrayData(): array + { + $pointerArray = ['Taylor', 'Otwell']; + + next($pointerArray); + + return [ + ['/:[a-z_]+/', ['8:30', '9:00'], 'The event will take place between :start and :end', 'The event will take place between 8:30 and 9:00'], + ['/%s/', ['Taylor'], 'Hi, %s', 'Hi, Taylor'], + ['/%s/', ['Taylor', 'Otwell'], 'Hi, %s %s', 'Hi, Taylor Otwell'], + ['/%s/', [], 'Hi, %s %s', 'Hi, '], + ['/%s/', ['a', 'b', 'c'], 'Hi', 'Hi'], + ['//', [], '', ''], + ['/%s/', ['a'], '', ''], + // non-sequential numeric keys → should still consume in natural order + ['/%s/', [2 => 'A', 10 => 'B'], '%s %s', 'A B'], + // associative keys → order should be insertion order, not keys/pointer + ['/%s/', ['first' => 'A', 'second' => 'B'], '%s %s', 'A B'], + // values that are "falsy" but must not be treated as empty by mistake, false->'' , null->'' + ['/%s/', ['0', 0, false, null], '%s|%s|%s|%s', '0|0||'], + // The internal pointer of this array is not at the beginning + ['/%s/', $pointerArray, 'Hi, %s %s', 'Hi, Taylor Otwell'], + ]; + } +} diff --git a/types/Log/Context.php b/types/Log/Context.php new file mode 100644 index 0000000000..a8315a0568 --- /dev/null +++ b/types/Log/Context.php @@ -0,0 +1,20 @@ +scope(fn (): int => random_int(-100, 100)); +assertType('int<-100, 100>', $value); + +$void = $repository->scope(function (): void { // @phpstan-ignore method.void +}); +assertType('null', $void); + +$repository->dehydrating(fn (Repository $context): Repository => $context->add('dehydrated', true)); +$repository->hydrated(fn (Repository $context): Repository => $context->add('hydrated', true)); From fa993e48121a9a2fee4e47a9e9f7d4d3b0d34176 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:46:13 +0000 Subject: [PATCH 05/23] Port HTTP retry callback parity and response throwUnless coverage Pass the HTTP method to retry callbacks for both synchronous and async requests. Synchronous callbacks use the captured request method, including middleware rewrites; async callbacks retain Laravel's original method argument. Make both synchronous lookups null-safe because caller-supplied clients bypass request-capturing middleware and middleware failures can occur before a request is captured. Port Laravel's early successful-response return while preserving Hypervel's response replacement callbacks, retry decision sharing and reset, transport exception conversion, pooled handlers, and coroutine cancellation behavior. Keep the existing concise null-coalescing expression and its precise PHPStan suppression instead of Laravel's analysis-only ternary rewrite. Restore all seven current upstream boolean/closure throwUnless and async retry-method tests. Preserve Hypervel's existing PendingRequest tests under accurate names and retain named-callback coverage. Add focused regressions for middleware-rewritten methods and custom clients, and extend the existing middleware-failure test to check the nullable method argument. Document callback signatures and the third HTTP-method argument. Correct Laravel's non-nullable exception annotation and documentation examples: non-error responses such as HTTP 304 pass null to the retry callback. The integer sleep callback annotation matches the synchronous delay path. Upstream PRs: https://github.com/laravel/framework/pull/57951 https://github.com/laravel/framework/pull/57943 https://github.com/laravel/framework/pull/61217 https://github.com/laravel/framework/pull/61106 https://github.com/laravel/framework/pull/55343 Synchronous method argument: laravel/framework commit 39b84dc961ab947b00b88754fc46cb01e14cf6b5. Port source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: HTTP client PHPUnit tests, HTTP package ParaTest, HTTP client facade integration tests, targeted formatting, full source/type-fixture PHPStan, and git diff --check pass. Existing HTTP package skips remain. --- src/docs/http-client.md | 6 +- src/http/src/Client/PendingRequest.php | 73 +++++----- tests/Http/HttpClientTest.php | 185 ++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 37 deletions(-) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 642a68940d..35ab84c9df 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -372,11 +372,13 @@ If needed, you may pass a third argument to the `retry` method. The third argume use Hypervel\Http\Client\PendingRequest; use Throwable; -$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { +$response = Http::retry(3, 100, function (?Throwable $exception, PendingRequest $request) { return $exception instanceof ConnectionException; })->post(/* ... */); ``` +The callback also receives the HTTP method as a third argument, or `null` when the method is unavailable. + If a request attempt fails, you may wish to make a change to the request before a new attempt is made. You can achieve this by modifying the request argument provided to the callable you provided to the `retry` method. For example, you might want to retry the request with a new authorization token if the first attempt returned an authentication error: ```php @@ -384,7 +386,7 @@ use Hypervel\Http\Client\PendingRequest; use Hypervel\Http\Client\RequestException; use Throwable; -$response = Http::withToken($this->getToken())->retry(2, 0, function (Throwable $exception, PendingRequest $request) { +$response = Http::withToken($this->getToken())->retry(2, 0, function (?Throwable $exception, PendingRequest $request) { if (! $exception instanceof RequestException || $exception->response->status() !== 401) { return false; } diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index b960c1e6b3..83478bdd5e 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -572,6 +572,9 @@ public function connectTimeout(float|int $seconds): static /** * Specify the number of times the request should be attempted. + * + * @param (Closure(int, mixed): int)|int $sleepMilliseconds + * @param null|(callable(null|Throwable, static, null|string): bool) $when */ public function retry( array|int $times, @@ -912,36 +915,40 @@ function (&$response) use ($attempt, &$shouldRetry) { $response = $this->runAfterResponseCallbacks($response); - if (! $response->successful()) { - try { - $shouldRetry = $this->retryWhenCallback ? call_user_func( - $this->retryWhenCallback, - $response->toException(), - $this - ) : true; - } catch (Exception $exception) { - $shouldRetry = false; - - throw $exception; - } - - if ($this->throwCallback - && ($this->throwIfCallback === null - || call_user_func($this->throwIfCallback, $response))) { - $response->throw($this->throwCallback); - } - - $potentialTries = is_array($this->tries) - ? count($this->tries) + 1 - : $this->tries; - - if ($attempt < $potentialTries && $shouldRetry) { - $response->throw(); - } - - if ($potentialTries > 1 && $this->retryThrow) { - $response->throw(); - } + if ($response->successful()) { + return; + } + + // A caller-supplied client bypasses the middleware that captures the request. + try { + $shouldRetry = $this->retryWhenCallback ? call_user_func( + $this->retryWhenCallback, + $response->toException(), + $this, + $this->request?->toPsrRequest()->getMethod() + ) : true; + } catch (Exception $exception) { + $shouldRetry = false; + + throw $exception; + } + + if ($this->throwCallback + && ($this->throwIfCallback === null + || call_user_func($this->throwIfCallback, $response))) { + $response->throw($this->throwCallback); + } + + $potentialTries = is_array($this->tries) + ? count($this->tries) + 1 + : $this->tries; + + if ($attempt < $potentialTries && $shouldRetry) { + $response->throw(); + } + + if ($potentialTries > 1 && $this->retryThrow) { + $response->throw(); } } ); @@ -964,7 +971,8 @@ function (&$response) use ($attempt, &$shouldRetry) { $result = $shouldRetry ?? ($this->retryWhenCallback ? call_user_func( // @phpstan-ignore nullCoalesce.variable ($shouldRetry is set by the retry callback closure via shared &$ref) $this->retryWhenCallback, $exception, - $this + $this, + $this->request?->toPsrRequest()->getMethod() ) : true); $shouldRetry = null; @@ -1126,7 +1134,8 @@ protected function handlePromiseResponse( $shouldRetry = $this->retryWhenCallback ? call_user_func( $this->retryWhenCallback, $response instanceof Response ? $response->toException() : $response, - $this + $this, + $method ) : true; } catch (CanceledException $exception) { throw $exception; diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index a86790c8b2..3a07b061c8 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -3539,6 +3539,74 @@ public function testAsyncRequestRetriesWithIntegerTries(): void $this->factory->assertSentCount(2); } + public function testAsyncRetryCallbackReceivesHttpMethod(): void + { + $method = null; + + $this->factory->fake([ + '*' => $this->factory->sequence() + ->push(['error'], 500) + ->push(['ok'], 200), + ]); + + $response = $this->factory + ->async() + ->retry(2, 0, function (Throwable $exception, PendingRequest $request, string $requestMethod) use (&$method): bool { + $method = $requestMethod; + + return true; + }, false) + ->get('http://foo.com/get') + ->wait(); + + $this->assertSame('GET', $method); + $this->assertTrue($response->successful()); + } + + public function testRetryCallbackReceivesHttpMethod(): void + { + $method = null; + + $this->factory->fake([ + '*' => $this->factory->sequence() + ->push(['error'], 500) + ->push(['ok'], 200), + ]); + + $response = $this->factory + ->withRequestMiddleware(static fn (RequestInterface $request): RequestInterface => $request->withMethod('PATCH')) + ->retry(2, 0, function (Throwable $exception, PendingRequest $request, ?string $requestMethod) use (&$method): bool { + $method = $requestMethod; + + return true; + }, false) + ->get('http://foo.com/get'); + + $this->assertSame('PATCH', $method); + $this->assertTrue($response->successful()); + } + + public function testRetryCallbackReceivesNullHttpMethodWithCustomClient(): void + { + $callbackCalled = false; + + $response = $this->factory + ->setClient(new GuzzleClient([ + 'handler' => static fn (): PromiseInterface => Factory::response('Failed', 500), + ])) + ->retry(1, when: function (Throwable $exception, PendingRequest $request, ?string $method) use (&$callbackCalled): bool { + $callbackCalled = true; + + $this->assertNull($method); + + return false; + }, throw: false) + ->get('http://foo.com/get'); + + $this->assertTrue($callbackCalled); + $this->assertSame(500, $response->status()); + } + public function testClientCanBeSet(): void { $client = $this->factory->buildClient(); @@ -4206,7 +4274,9 @@ public function testExceptionThrowInMiddlewareAllowsRetry(): void $this->factory->fake(function (Request $request) { return $this->factory::response('Fake'); })->withMiddleware($middleware) - ->retry(3, 1, function (Exception $exception, PendingRequest $request) { + ->retry(3, 1, function (Exception $exception, PendingRequest $request, ?string $method): bool { + $this->assertNull($method); + return true; })->post('https://example.com'); } @@ -5048,7 +5118,7 @@ public function testRequestExceptionIsNotThrownIfTheThrowIfOnThePendingRequestIs $this->assertSame(403, $response->status()); } - public function testRequestExceptionIsThrownWhenUnlessConditionIsNotSatisfied(): void + public function testPendingRequestExceptionIsThrownWhenUnlessConditionIsNotSatisfied(): void { $this->factory->fake([ '*' => $this->factory::response('', 400), @@ -5059,7 +5129,7 @@ public function testRequestExceptionIsThrownWhenUnlessConditionIsNotSatisfied(): $this->factory->throwUnless(false)->get('http://foo.com/api'); } - public function testRequestExceptionIsNotThrownWhenUnlessConditionIsSatisfied(): void + public function testPendingRequestExceptionIsNotThrownWhenUnlessConditionIsSatisfied(): void { $this->factory->fake([ '*' => $this->factory::response(['result' => ['foo' => 'bar']], 400), @@ -5201,6 +5271,49 @@ public function testPendingRequestThrowUnlessEvaluatesCallableConditionAndForwar $this->assertSame(403, $response->status()); } + public function testRequestExceptionIsThrownIfTheThrowUnlessClosureOnThePendingRequestReturnsFalse(): void + { + $this->factory->fake([ + '*' => $this->factory::response(['error'], 403), + ]); + + $exception = null; + + try { + $this->factory + ->throwUnless(function (Response $response): bool { + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(403, $response->status()); + + return false; + }) + ->get('http://foo.com/get'); + } catch (RequestException $e) { + $exception = $e; + } + + $this->assertNotNull($exception); + $this->assertInstanceOf(RequestException::class, $exception); + } + + public function testRequestExceptionIsNotThrownIfTheThrowUnlessClosureOnThePendingRequestReturnsTrue(): void + { + $this->factory->fake([ + '*' => $this->factory::response(['error'], 403), + ]); + + $response = $this->factory + ->throwUnless(function (Response $response): bool { + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(403, $response->status()); + + return true; + }) + ->get('http://foo.com/get'); + + $this->assertSame(403, $response->status()); + } + public function testRequestExceptionIsThrownWithCallbackIfThePendingRequestIsSetToThrowOnFailure(): void { $this->factory->fake([ @@ -5309,6 +5422,35 @@ public function testRequestExceptionIsNotThrownIfConditionIsNotSatisfied(): void $this->assertSame('{"result":{"foo":"bar"}}', $response->body()); } + public function testRequestExceptionIsThrownWhenUnlessConditionIsNotSatisfied(): void + { + $this->factory->fake([ + '*' => $this->factory::response('', 400), + ]); + + $exception = null; + + try { + $this->factory->get('http://foo.com/api')->throwUnless(false); + } catch (RequestException $e) { + $exception = $e; + } + + $this->assertNotNull($exception); + $this->assertInstanceOf(RequestException::class, $exception); + } + + public function testRequestExceptionIsNotThrownWhenUnlessConditionIsSatisfied(): void + { + $this->factory->fake([ + '*' => $this->factory::response(['result' => ['foo' => 'bar']], 400), + ]); + + $response = $this->factory->get('http://foo.com/api')->throwUnless(true); + + $this->assertSame('{"result":{"foo":"bar"}}', $response->body()); + } + public function testRequestExceptionIsThrowIfConditionClosureIsSatisfied(): void { $this->factory->fake([ @@ -5395,6 +5537,43 @@ public function testResponseThrowConditionsForwardNamedCallbacks(): void } } + public function testRequestExceptionIsThrownWhenUnlessConditionClosureIsNotSatisfied(): void + { + $this->factory->fake([ + '*' => $this->factory::response('', 400), + ]); + + $exception = null; + + try { + $this->factory->get('http://foo.com/api')->throwUnless(function (Response $response): bool { + $this->assertSame(400, $response->status()); + + return false; + }); + } catch (RequestException $e) { + $exception = $e; + } + + $this->assertNotNull($exception); + $this->assertInstanceOf(RequestException::class, $exception); + } + + public function testRequestExceptionIsNotThrownWhenUnlessConditionClosureIsSatisfied(): void + { + $this->factory->fake([ + '*' => $this->factory::response(['result' => ['foo' => 'bar']], 400), + ]); + + $response = $this->factory->get('http://foo.com/api')->throwUnless(function (Response $response): bool { + $this->assertSame(400, $response->status()); + + return true; + }); + + $this->assertSame('{"result":{"foo":"bar"}}', $response->body()); + } + public function testRequestExceptionIsThrownIfStatusCodeIsSatisfied(): void { $this->factory->fake([ From aac3f00b0a2ae5296640ae22d8a3e50c9a864516 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:18:41 +0000 Subject: [PATCH 06/23] Port HTTP fake stream bodies and correct response sequence input types Port Laravel framework PR #61047 from 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/61047 Reject non-stream resources at the fake-response factory with the upstream InvalidArgumentException instead of allowing Guzzle's stream conversion to fail later. Preserve JSON encoding errors and existing header normalization. Restore the supported body PHPDocs on response, psr7Response, failedRequest and ResponseSequence::push. Resources cannot be represented by a native PHP union, so push now uses mixed with the finite upstream PHPDoc contract. Previously it rejected valid stream resources and PSR-7 streams from strict callers, and weak callers coerced PSR-7 streams to strings prematurely. Port both current upstream rejection tests, reusing and renaming the existing unsupported-object case. Add a focused sequence regression for both stream forms with exception-safe cleanup, and document the accepted public inputs. Validation: changed HTTP tests, the complete HTTP ParaTest suite and HTTP facade integration tests pass. Full source/type PHPStan, scoped formatting and diff checks pass. Self-review and independent code review are complete. --- src/docs/http-client.md | 2 ++ src/http/src/Client/Factory.php | 10 ++++-- src/http/src/Client/ResponseSequence.php | 5 ++- tests/Http/HttpClientTest.php | 46 +++++++++++++++++++----- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 35ab84c9df..df115a61e2 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -1045,6 +1045,8 @@ Http::fake([ ]); ``` +The `push` method accepts the same response bodies as `Http::response`, including PHP stream resources and PSR-7 streams. + When all the responses in a response sequence have been consumed, any further requests will cause the response sequence to throw an exception. If you would like to specify a default response that should be returned when a sequence is empty, you may use the `whenEmpty` method: ```php diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index b593705060..776f3de05a 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -151,6 +151,8 @@ public function globalOptions(array|Closure $options): static /** * Create a new response instance for use during stubbing. + * + * @param null|array|resource|StreamInterface|string $body */ public static function response( mixed $body = null, @@ -165,6 +167,8 @@ public static function response( /** * Create a new PSR-7 response instance for use during stubbing. * + * @param null|array|resource|StreamInterface|string $body + * * @throws InvalidArgumentException */ public static function psr7Response( @@ -182,8 +186,8 @@ public static function psr7Response( $headers['Content-Type'] = 'application/json'; } - if (! is_string($body) && ! is_null($body) && ! is_resource($body) && ! $body instanceof StreamInterface) { - throw new InvalidArgumentException('HTTP fake response body must be a string, array, resource, Psr\Http\Message\StreamInterface, or null.'); + if (! is_string($body) && ! is_null($body) && (! is_resource($body) || get_resource_type($body) !== 'stream') && ! $body instanceof StreamInterface) { + throw new InvalidArgumentException('HTTP fake response body must be a string, array, stream resource, Psr\Http\Message\StreamInterface, or null.'); } return new Psr7Response($status, static::normalizeResponseHeaders($headers), $body); @@ -247,6 +251,8 @@ protected static function normalizeScalarString(bool|float|int|string $value): s /** * Create a new RequestException instance for use during stubbing. + * + * @param null|array|resource|StreamInterface|string $body */ public static function failedRequest( mixed $body = null, diff --git a/src/http/src/Client/ResponseSequence.php b/src/http/src/Client/ResponseSequence.php index 36f996ac59..e7f0006b78 100644 --- a/src/http/src/Client/ResponseSequence.php +++ b/src/http/src/Client/ResponseSequence.php @@ -8,6 +8,7 @@ use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Support\Traits\Macroable; use OutOfBoundsException; +use Psr\Http\Message\StreamInterface; class ResponseSequence { @@ -33,8 +34,10 @@ public function __construct( /** * Push a response to the sequence. + * + * @param null|array|resource|StreamInterface|string $body */ - public function push(array|string|null $body = null, int $status = 200, array $headers = []): static + public function push(mixed $body = null, int $status = 200, array $headers = []): static { return $this->pushResponse( Factory::response($body, $status, $headers) diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 3a07b061c8..ae929239a0 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -190,14 +190,6 @@ public static function invalidFakeResponseHeaderValuesProvider(): array ]; } - public function testInvalidFakeResponseBodyValuesAreRejected(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('HTTP fake response body must be a string, array, resource, Psr\Http\Message\StreamInterface, or null.'); - - $this->factory::response(new stdClass); - } - public function testInvalidJsonFakeResponseBodyValuesAreRejected(): void { $this->expectException(InvalidArgumentException::class); @@ -244,6 +236,20 @@ public function testFakeResponseSupportsResourceBody(): void $this->assertSame('Hello World', (string) $response->getBody()); } + public function testFakeResponseRejectsUnsupportedBody(): void + { + $this->expectExceptionObject(new InvalidArgumentException('HTTP fake response body must be a string, array, stream resource, Psr\Http\Message\StreamInterface, or null.')); + + $this->factory::response(new stdClass); + } + + public function testFakeResponseRejectsNonStreamResourceBody(): void + { + $this->expectExceptionObject(new InvalidArgumentException('HTTP fake response body must be a string, array, stream resource, Psr\Http\Message\StreamInterface, or null.')); + + $this->factory::response(stream_context_create()); + } + public function testAcceptedRequest(): void { $this->factory->fake([ @@ -2063,6 +2069,30 @@ public function testSequenceBuilder(): void $this->factory->get('https://example.com'); } + public function testSequenceBuilderSupportsStreamBodies(): void + { + $stream = Utils::streamFor('PSR-7 stream body'); + $resource = fopen('php://temp', 'w+'); + + try { + fwrite($resource, 'resource body'); + rewind($resource); + + $this->factory->fakeSequence() + ->push($stream) + ->push($resource); + + $this->assertSame('PSR-7 stream body', $this->factory->get('https://example.com')->body()); + $this->assertSame('resource body', $this->factory->get('https://example.com')->body()); + } finally { + $stream->close(); + + if (is_resource($resource)) { + fclose($resource); + } + } + } + public function testSequenceBuilderCanKeepGoingWhenEmpty(): void { $this->factory->fake([ From 3844c8a24df8d62779676a5595c6b7f2fae3d968 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:18:41 +0000 Subject: [PATCH 07/23] Complete queue idle-stop coverage and align command descriptions Reconcile Laravel framework PRs #58058 and #60176 against 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58058 https://github.com/laravel/framework/pull/60176 Port the six queue:listen and queue:work option-description corrections. Describe stop-when-empty-for using the interval since processing a job, rather than claiming the timer starts when the queue becomes empty. Correct the matching WorkerOptions explanation, Horizon consumer and queue docs. The runtime feature was already adapted for concurrent workers. Preserve its per-run reset, completion timestamps, queue-poll eligibility and running-job checks. Restore the complete upstream WorkerStopping status, options identity and reason assertions in both existing idle-period regressions. Keep the Hypervel clock assertions, exact event counts and running-job coverage. Validation: changed QueueWorkerTest, focused queue command/listener/worker ParaTest tests and Horizon command tests pass. Full source/type PHPStan, scoped formatting and diff checks pass. Parsed option names and defaults remain unchanged. Self-review and independent code review are complete. --- src/docs/queues.md | 2 +- src/horizon/src/Console/WorkCommand.php | 2 +- src/queue/src/Console/ListenCommand.php | 6 +++--- src/queue/src/Console/WorkCommand.php | 8 ++++---- src/queue/src/WorkerOptions.php | 2 +- tests/Queue/QueueWorkerTest.php | 10 +++++++++- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index bc81f00988..d0ef346183 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -2680,7 +2680,7 @@ The `--stop-when-empty` option may be used to instruct the worker to process all php artisan queue:work --stop-when-empty ``` -The `--stop-when-empty-for` option may be used to keep the worker alive until the queue has remained empty for a given number of seconds. The timer begins when the worker starts and resets whenever a job finishes: +The `--stop-when-empty-for` option may be used to stop the worker when no jobs have been processed for a given number of seconds. The timer begins when the worker starts and resets whenever a job finishes: ```shell php artisan queue:work --stop-when-empty-for=30 diff --git a/src/horizon/src/Console/WorkCommand.php b/src/horizon/src/Console/WorkCommand.php index 27925e42a7..57bf19b94b 100644 --- a/src/horizon/src/Console/WorkCommand.php +++ b/src/horizon/src/Console/WorkCommand.php @@ -19,7 +19,7 @@ class WorkCommand extends BaseWorkCommand {--once : Only process the next job on the queue} {--concurrency=1 : The number of jobs to process at once} {--stop-when-empty : Stop when the queue is empty} - {--stop-when-empty-for=0 : Stop when the queue has been empty for the given number of seconds} + {--stop-when-empty-for=0 : Stop when no jobs have been processed for the given number of seconds} {--delay=0 : The number of seconds to delay failed jobs (Deprecated)} {--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception} {--max-jobs=0 : The number of jobs to process before stopping} diff --git a/src/queue/src/Console/ListenCommand.php b/src/queue/src/Console/ListenCommand.php index 0f539c0f36..82e52b60be 100644 --- a/src/queue/src/Console/ListenCommand.php +++ b/src/queue/src/Console/ListenCommand.php @@ -25,10 +25,10 @@ class ListenCommand extends Command {--force : Force the worker to run even in maintenance mode} {--memory=128 : The memory limit in megabytes} {--queue= : The queue to listen on} - {--sleep=3 : Number of seconds to sleep when no job is available} - {--rest=0 : Number of seconds to rest between jobs} + {--sleep=3 : The number of seconds to sleep when no job is available} + {--rest=0 : The number of seconds to rest between jobs} {--timeout=60 : The number of seconds a child process can run} - {--tries=1 : Number of times to attempt a job before logging it failed}'; + {--tries=1 : The number of times to attempt a job before logging it failed}'; /** * The console command description. diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index 034033368e..d6c2b265ea 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -49,18 +49,18 @@ class WorkCommand extends Command {--once : Only process the next job on the queue} {--concurrency= : The number of jobs to process at once} {--stop-when-empty : Stop when the queue is empty} - {--stop-when-empty-for=0 : Stop when the queue has been empty for the given number of seconds} + {--stop-when-empty-for=0 : Stop when no jobs have been processed for the given number of seconds} {--delay=0 : The number of seconds to delay failed jobs (Deprecated)} {--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception} {--max-jobs=0 : The number of jobs to process before stopping} {--max-time=0 : The maximum number of seconds the worker should run} {--force : Force the worker to run even in maintenance mode} {--memory=128 : The memory limit in megabytes} - {--sleep=3 : Number of seconds to sleep when no job is available} - {--rest=0 : Number of seconds to rest between jobs} + {--sleep=3 : The number of seconds to sleep when no job is available} + {--rest=0 : The number of seconds to rest between jobs} {--timeout=60 : The number of seconds a child process can run} {--monitor-interval=1 : The time interval of seconds for monitoring timeout jobs} - {--tries=1 : Number of times to attempt a job before logging it failed} + {--tries=1 : The number of times to attempt a job before logging it failed} {--json : Output the queue worker information as JSON}'; /** diff --git a/src/queue/src/WorkerOptions.php b/src/queue/src/WorkerOptions.php index dca3fd2254..138f111f78 100644 --- a/src/queue/src/WorkerOptions.php +++ b/src/queue/src/WorkerOptions.php @@ -20,7 +20,7 @@ class WorkerOptions * @param int $maxJobs the maximum number of jobs to run * @param int $maxTime the maximum number of seconds a worker may live * @param int $rest the number of seconds to rest between jobs - * @param int $stopWhenEmptyFor the number of seconds the queue may remain empty + * @param int $stopWhenEmptyFor the number of seconds without processing a job before stopping * @param int $concurrency the number of jobs to process at once * @param int $monitorInterval the number of seconds between timeout scans * @param array $coroutineContext context values to seed while each job runs diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 88858b6cae..f8e25fd6b8 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -785,11 +785,13 @@ public function testWorkerStopsWhenQueueIsEmptyForConfiguredSeconds(): void $this->events->shouldHaveReceived('dispatch')->with(m::type(WorkerIdle::class))->twice(); $this->events->shouldHaveReceived('dispatch')->with(m::on( fn (object $event): bool => $event instanceof WorkerStopping + && $event->status === Worker::EXIT_SUCCESS + && $event->workerOptions === $workerOptions && $event->reason === WorkerStopReason::QueueEmptyFor ))->once(); } - public function testWorkerResetsQueueEmptyTimerAfterAJobCompletes(): void + public function testWorkerResetsQueueEmptyTimerAfterProcessingJob(): void { $workerOptions = new WorkerOptions(stopWhenEmptyFor: 5); $worker = $this->getWorker('default', ['queue' => [ @@ -805,6 +807,12 @@ public function testWorkerResetsQueueEmptyTimerAfterAJobCompletes(): void $this->assertTrue($job->fired); $this->assertSame(16.0, $worker->currentTime); $this->events->shouldHaveReceived('dispatch')->with(m::type(WorkerIdle::class))->twice(); + $this->events->shouldHaveReceived('dispatch')->with(m::on( + fn (object $event): bool => $event instanceof WorkerStopping + && $event->status === Worker::EXIT_SUCCESS + && $event->workerOptions === $workerOptions + && $event->reason === WorkerStopReason::QueueEmptyFor + ))->once(); } public function testWorkerDoesNotStopForAnEmptyQueueWhileAJobIsRunning(): void From a9c66b01715e883582de64e511271ffda0091139 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:53:43 +0000 Subject: [PATCH 08/23] Complete event and batch callback type contracts Port the remaining event annotations from Laravel #57986 using the corrected current forms from #58963. Preserve subscriber resolution through arbitrary container keys, object-method listener pairs and invokable listeners. Queued callbacks now declare their existing void return without changing dispatch, argument cloning, queue ownership or coroutine-local state. Describe allowFailures callbacks inline with their Batch and nullable Throwable arguments. Upstream's method-local PHPStan type alias does not resolve; the inline contract fixes that defect without adding a one-use class-level alias. Keep Hypervel's accurate mixed halted-listener returns, nested listener maps, QueueFactory resolver and nullable transaction-manager resolver. Blade's bound callables remain broader than upstream's Closure-only annotation because compatible compiler-method callables work through Closure::fromCallable. Upstream: https://github.com/laravel/framework/pull/57986 https://github.com/laravel/framework/pull/58963 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: Events and Bus suites pass under ParaTest; full source and type fixture analysis, formatting and diff checks pass. Independently reviewed. --- src/bus/src/PendingBatch.php | 4 ++-- src/events/src/Dispatcher.php | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/bus/src/PendingBatch.php b/src/bus/src/PendingBatch.php index 227072f0ee..ab42b52773 100644 --- a/src/bus/src/PendingBatch.php +++ b/src/bus/src/PendingBatch.php @@ -182,11 +182,11 @@ public function finallyCallbacks(): array } /** - * Indicate that the batch should not be cancelled when a job within the batch fails. + * Indicate that the batch should not be canceled when a job within the batch fails. * * Optionally, add callbacks to be executed upon each job failure. * - * @param array|bool|callable $param + * @param array|bool|(callable(Batch, ?Throwable): mixed) $param */ public function allowFailures(mixed $param = true): static { diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index bc2e15fa8a..ed28af727a 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -395,6 +395,8 @@ public function subscribe(object|string $subscriber): void /** * Resolve the subscriber instance. + * + * @return ($subscriber is object ? object : mixed) */ protected function resolveSubscriber(object|string $subscriber): mixed { @@ -747,6 +749,8 @@ protected function prepareWildcardObservers(string $eventName): array /** * Register an event listener with the dispatcher. + * + * @param array{class-string|object, string}|object|string $listener */ public function makeListener(array|object|string $listener, bool $wildcard = false): Closure { @@ -852,6 +856,10 @@ protected function parseClassCallable(string $listener): array /** * Determine if the event handler class should be queued. + * + * @param class-string $class + * + * @phpstan-assert-if-true class-string $class */ protected function handlerShouldBeQueued(string $class): bool { @@ -868,10 +876,11 @@ protected function handlerShouldBeQueued(string $class): bool * Create a callable for putting an event handler on the queue. * * @param class-string $class + * @return Closure(): void */ protected function createQueuedHandlerCallable(string $class, string $method): Closure { - return function () use ($class, $method) { + return function () use ($class, $method): void { $arguments = array_map(function ($a) { return is_object($a) && ! $a instanceof UnitEnum ? clone $a : $a; }, func_get_args()); From 8552a86752c00cd2c5f833a4937f8fcaa2e53e3d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:54:03 +0000 Subject: [PATCH 09/23] Complete upstream string-trimming tests and document wildcard exclusions Merge all thirteen current upstream HTTP TrimStrings tests into the existing Foundation test class. Preserve every literal and assertion, including nested wildcard exclusions, global exclusions, zero-width characters and repeated invisible-character combinations. Retain Hypervel's existing tests and use the framework's shared static cleanup instead of adding another teardown path. The implementation already supports these cases through Str::is and Str::trim, including Hypervel's early return for non-string input. Keep that behavior and add a concise bootstrap example for attribute names and wildcard exclusions. The pinned Laravel requests and middleware docs do not describe this option. Upstream: https://github.com/laravel/framework/pull/57982 https://github.com/laravel/framework/pull/44906 The global-exclusion test originates in https://github.com/laravel/framework/pull/47309; its broader slim-skeleton changes remain a separate parity investigation. Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Documentation reference: laravel/docs at 2914ba0b06c6be40c2f1f992555853f6266707d6. Validation: the complete middleware test class passes. Source/type analysis, formatting and diff checks pass. Ported string literals and assertions were compared against upstream, with an independent review of the invisible bytes. --- src/docs/requests.md | 11 + .../Http/Middleware/TrimStringsTest.php | 232 ++++++++++++++++++ 2 files changed, 243 insertions(+) diff --git a/src/docs/requests.md b/src/docs/requests.md index 311c5b5b41..d3280a7ff1 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -810,6 +810,17 @@ use Hypervel\Http\Request; }) ``` +You may also exclude individual attributes from trimming by passing their names to the `trimStrings` method. Use `*` to match nested attributes: + +```php +->withMiddleware(function (Middleware $middleware): void { + $middleware->trimStrings(except: [ + 'title', + 'users.*.name', + ]); +}) +``` + ## Files diff --git a/tests/Foundation/Http/Middleware/TrimStringsTest.php b/tests/Foundation/Http/Middleware/TrimStringsTest.php index be3c600e8a..f4a6ce8afa 100644 --- a/tests/Foundation/Http/Middleware/TrimStringsTest.php +++ b/tests/Foundation/Http/Middleware/TrimStringsTest.php @@ -140,6 +140,238 @@ public function testTrimStringsNBSP() }); } + public function testNoZeroWidthSpaceCharacterReturnsTheSameString(): void + { + $request = new Request; + + $request->merge([ + 'title' => 'This title does not contain any zero-width space', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title does not contain any zero-width space', $request->title); + }); + } + + public function testLeadingZeroWidthSpaceCharacterIsTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => '​This title contains a zero-width space at the beginning', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a zero-width space at the beginning', $request->title); + }); + } + + public function testTrimStringsCanGloballyIgnoreCertainInputs(): void + { + $request = new Request; + + $request->merge([ + 'globally_ignored_title' => ' test title ', + ]); + + TrimStrings::except(['globally_ignored_title']); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame(' test title ', $request->globally_ignored_title); + }); + } + + public function testTrailingZeroWidthSpaceCharacterIsTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => 'This title contains a zero-width space at the end​', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a zero-width space at the end', $request->title); + }); + } + + public function testLeadingZeroWidthNonBreakableSpaceCharacterIsTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => 'This title contains a zero-width non-breakable space at the beginning', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a zero-width non-breakable space at the beginning', $request->title); + }); + } + + public function testLeadingMultipleZeroWidthNonBreakableSpaceCharactersAreTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => 'This title contains a zero-width non-breakable space at the beginning', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a zero-width non-breakable space at the beginning', $request->title); + }); + } + + public function testCombinationOfLeadingAndTrailingZeroWidthNonBreakableSpaceAndZeroWidthSpaceCharactersAreTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => '​This title contains a combination of zero-width non-breakable space and zero-width spaces characters at the beginning and the end​', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a combination of zero-width non-breakable space and zero-width spaces characters at the beginning and the end', $request->title); + }); + } + + public function testLeadingInvisibleCharactersAreTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => '‎This title contains a invisible character at the beginning', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a invisible character at the beginning', $request->title); + }); + } + + public function testTrailingInvisibleCharactersAreTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => 'This title contains a invisible character at the end‎', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a invisible character at the end', $request->title); + }); + } + + public function testLeadingMultipleInvisibleCharactersAreTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => '‎‎This title contains a invisible character at the beginning', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a invisible character at the beginning', $request->title); + }); + } + + public function testTrailingMultipleInvisibleCharactersAreTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => 'This title contains a invisible character at the end‎‎', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a invisible character at the end', $request->title); + }); + } + + public function testCombinationOfLeadingAndTrailingMultipleInvisibleCharactersAreTrimmed(): void + { + $request = new Request; + + $request->merge([ + 'title' => '‎‎This title contains a combination of a invisible character at beginning and the end‎‎', + ]); + + $middleware = new TrimStrings; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame('This title contains a combination of a invisible character at beginning and the end', $request->title); + }); + } + + public function testTrimStringsCanIgnoreNestedAttributesUsingWildcards(): void + { + $request = new Request; + + $request->merge([ + 'users' => [ + ['name' => ' foo ', 'role' => ' admin '], + ['name' => ' bar ', 'role' => ' editor '], + ], + 'teams' => [ + ['name' => ' team '], + ], + 'orders' => [ + [ + 'items' => [ + ['meta' => ['title' => ' foo ', 'sku' => ' SKU-1 ', 'tags' => [' alpha ']]], + ], + ], + [ + 'items' => [ + ['meta' => ['title' => ' bar ', 'sku' => ' SKU-2 ', 'tags' => [' beta ']]], + ], + ], + ], + ]); + + $middleware = new class extends TrimStrings { + protected array $except = [ + 'users.*.name', + 'orders.*.items.*.meta.title', + 'orders.*.items.*.meta.tags.*', + ]; + }; + + $middleware->handle($request, function (Request $request): void { + $this->assertSame(' foo ', $request->input('users.0.name')); + $this->assertSame(' bar ', $request->input('users.1.name')); + $this->assertSame('admin', $request->input('users.0.role')); + $this->assertSame('editor', $request->input('users.1.role')); + $this->assertSame('team', $request->input('teams.0.name')); + $this->assertSame(' foo ', $request->input('orders.0.items.0.meta.title')); + $this->assertSame('SKU-1', $request->input('orders.0.items.0.meta.sku')); + $this->assertSame(' alpha ', $request->input('orders.0.items.0.meta.tags.0')); + + $this->assertSame(' bar ', $request->input('orders.1.items.0.meta.title')); + $this->assertSame('SKU-2', $request->input('orders.1.items.0.meta.sku')); + $this->assertSame(' beta ', $request->input('orders.1.items.0.meta.tags.0')); + }); + } + private function handle(TrimStrings $middleware, array $input): Request { $symfonyRequest = new SymfonyRequest($input); From 26f0fa4e9ffa5b2a6a721d44f3d0a8210493f1db Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:31:58 +0000 Subject: [PATCH 10/23] Fix reload command exclusion coverage and package guidance The command-name exclusion test checked for a command identifier that ReloadCommand never prints. Assert absence of the displayed task label so the test detects a failure to exclude by command, while retaining the existing positive and default-task controls. Add native void return types to the test methods. Correct the package guide's inherited claim that reload terminates every service. Hypervel reloads server workers without terminating the master; the existing deployment guide owns the operational explanation. Reconciles Laravel's reload command and provider registration: https://github.com/laravel/framework/pull/57923 Laravel source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 (13.x). The command implementation was already adapted, including Hypervel's server reload task; this fixes its existing test and documentation. Validation: ReloadCommandTest and scoped PHP-CS-Fixer pass; diff check is clean. No runtime source changes. --- src/docs/packages.md | 2 +- .../Foundation/Console/ReloadCommandTest.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/docs/packages.md b/src/docs/packages.md index a79959c951..495013decb 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -646,7 +646,7 @@ public function boot(): void ### Reload Commands -Hypervel's [reload command](/docs/{{version}}/deployment#reloading-services) terminates any running services so they can be automatically restarted by a system process monitor. Using the `reloads` method, you may register your package's own Artisan commands that should be invoked when the `reload` command is executed: +You may register commands that should run when Hypervel's [reload command](/docs/{{version}}/deployment#reloading-services) is executed using the `reloads` method: ```php /** diff --git a/tests/Integration/Foundation/Console/ReloadCommandTest.php b/tests/Integration/Foundation/Console/ReloadCommandTest.php index 406a9bfbed..0cc582b1f3 100644 --- a/tests/Integration/Foundation/Console/ReloadCommandTest.php +++ b/tests/Integration/Foundation/Console/ReloadCommandTest.php @@ -16,28 +16,28 @@ protected function getPackageProviders(ApplicationContract $app): array return [ServiceProviderWithReload::class]; } - public function testCanRunReloadWithPackageRegisteredCommand() + public function testCanRunReloadWithPackageRegisteredCommand(): void { $this->artisan('reload') ->assertSuccessful() ->expectsOutputToContain('my service'); } - public function testCanExcludeCommandsByKey() + public function testCanExcludeCommandsByKey(): void { $this->artisan('reload', ['--except' => 'my service']) ->assertSuccessful() ->doesntExpectOutputToContain('my service'); } - public function testCanExcludeCommandsByCommand() + public function testCanExcludeCommandsByCommand(): void { $this->artisan('reload', ['--except' => 'my_service:reload']) ->assertSuccessful() - ->doesntExpectOutputToContain('my_service:reload'); + ->doesntExpectOutputToContain('my service'); } - public function testIncludesDefaultTasks() + public function testIncludesDefaultTasks(): void { $this->artisan('reload') ->assertSuccessful() From 6c71de8be766f424675b85b15db1a996cae10fb5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:32:23 +0000 Subject: [PATCH 11/23] Port Markdown mail theme, layout slots and language metadata Bring the default mail stylesheet forward to the current Laravel 13.x neutral palette, card styling, logo spacing, logical alignment and long link wrapping. Preserve Hypervel branding and existing renderer behavior. Add the HTML layout's head slot and locale-derived lang attribute. Document direct layout customization, the explicit header/footer slots, and the existing CommonMark extension configuration. Preserve the adapted mail rendering and custom-theme tests that upstream removed when introducing extension tests; those tests still cover distinct behavior. Correct an upstream omission in the RTL alignment update: standalone p and h3 rules still forced left alignment, overriding the grouped p rule and leaving third-level headings inconsistent with h1/h2. Use start for both, while retaining intentional centered container/footer alignment. The stylesheet otherwise matches the pinned source exactly. One integration fixture and test verify head content in the rendered head, mailable locale normalization, and final inlined p/h3 alignment. No new runtime state, compatibility branches or rendering machinery. Upstream PRs: https://github.com/laravel/framework/pull/53906 (long mail links) https://github.com/laravel/framework/pull/57987 (theme modernization) https://github.com/laravel/framework/pull/58935 (logical alignment) https://github.com/laravel/framework/pull/53531 (head slot) https://github.com/laravel/framework/pull/58274 (language attribute) https://github.com/laravel/framework/pull/59051 (extension configuration; source and tests already present, public documentation completed here) Laravel source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 (13.x). Docs consulted: 2914ba0b06c6be40c2f1f992555853f6266707d6. Validation: affected Markdown test, complete Mail unit and integration suites via ParaTest, scoped PHP-CS-Fixer and diff checks pass. Confirmed the upstream p/h3 alignment defect through the installed CSS inliner. --- src/docs/mail.md | 34 ++++++++ .../resources/views/html/layout.blade.php | 3 +- .../resources/views/html/themes/default.css | 86 ++++++++++--------- .../Mail/Fixtures/layout-with-head.blade.php | 11 +++ .../Mail/SendingMarkdownMailTest.php | 13 +++ 5 files changed, 106 insertions(+), 41 deletions(-) create mode 100644 tests/Integration/Mail/Fixtures/layout-with-head.blade.php diff --git a/src/docs/mail.md b/src/docs/mail.md index a33c9f8d5f..415bd9e94d 100644 --- a/src/docs/mail.md +++ b/src/docs/mail.md @@ -994,6 +994,40 @@ If you would like to build an entirely new theme for Hypervel's Markdown compone To customize the theme for an individual mailable, you may set the `$theme` property of the mailable class to the name of the theme that should be used when sending that mailable. + +#### Customizing the Layout + +When using the `mail::layout` component directly, you may provide a `head` slot to add styles or metadata without publishing the layout. The `mail::message` component does not forward this slot: + +```blade + + + + + +# Order Shipped + +Your order has shipped! + +``` + +The layout does not include a header or footer by default; add `header` and `footer` slots containing the `mail::header` and `mail::footer` components if needed. + + +#### Markdown Extensions + +You may enable additional CommonMark extensions by adding their classes to the `extensions` array under `markdown` in your application's `config/mail.php` configuration file: + +```php +'extensions' => [ + \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class, +], +``` + ## Sending Mail diff --git a/src/mail/resources/views/html/layout.blade.php b/src/mail/resources/views/html/layout.blade.php index bb909d9933..dde46c6fc1 100644 --- a/src/mail/resources/views/html/layout.blade.php +++ b/src/mail/resources/views/html/layout.blade.php @@ -1,5 +1,5 @@ - + {{ config()->string('app.name') }} @@ -23,6 +23,7 @@ } } +{!! $head ?? '' !!} diff --git a/src/mail/resources/views/html/themes/default.css b/src/mail/resources/views/html/themes/default.css index 09e31d8787..9e5da1919c 100644 --- a/src/mail/resources/views/html/themes/default.css +++ b/src/mail/resources/views/html/themes/default.css @@ -11,7 +11,7 @@ body *:not(html):not(style):not(br):not(tr):not(code) { body { -webkit-text-size-adjust: none; background-color: #ffffff; - color: #718096; + color: #52525b; height: 100%; line-height: 1.4; margin: 0; @@ -24,11 +24,11 @@ ul, ol, blockquote { line-height: 1.4; - text-align: left; + text-align: start; } a { - color: #3869d4; + color: #18181b; } a img { @@ -38,32 +38,32 @@ a img { /* Typography */ h1 { - color: #3d4852; + color: #18181b; font-size: 18px; font-weight: bold; margin-top: 0; - text-align: left; + text-align: start; } h2 { font-size: 16px; font-weight: bold; margin-top: 0; - text-align: left; + text-align: start; } h3 { font-size: 14px; font-weight: bold; margin-top: 0; - text-align: left; + text-align: start; } p { font-size: 16px; line-height: 1.5em; margin-top: 0; - text-align: left; + text-align: start; } p.sub { @@ -80,7 +80,7 @@ img { -premailer-cellpadding: 0; -premailer-cellspacing: 0; -premailer-width: 100%; - background-color: #edf2f7; + background-color: #fafafa; margin: 0; padding: 0; width: 100%; @@ -103,7 +103,7 @@ img { } .header a { - color: #3d4852; + color: #18181b; font-size: 19px; font-weight: bold; text-decoration: none; @@ -113,6 +113,8 @@ img { .logo { height: 75px; + margin-top: 15px; + margin-bottom: 10px; max-height: 75px; width: 75px; } @@ -123,9 +125,9 @@ img { -premailer-cellpadding: 0; -premailer-cellspacing: 0; -premailer-width: 100%; - background-color: #edf2f7; - border-bottom: 1px solid #edf2f7; - border-top: 1px solid #edf2f7; + background-color: #fafafa; + border-bottom: 1px solid #fafafa; + border-top: 1px solid #fafafa; margin: 0; padding: 0; width: 100%; @@ -136,19 +138,23 @@ img { -premailer-cellspacing: 0; -premailer-width: 570px; background-color: #ffffff; - border-color: #e8e5ef; - border-radius: 2px; + border-color: #e4e4e7; + border-radius: 4px; border-width: 1px; - box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); margin: 0 auto; padding: 0; width: 570px; } +.inner-body a { + word-break: break-all; +} + /* Subcopy */ .subcopy { - border-top: 1px solid #e8e5ef; + border-top: 1px solid #e4e4e7; margin-top: 25px; padding-top: 25px; } @@ -170,13 +176,13 @@ img { } .footer p { - color: #b0adc5; + color: #a1a1aa; font-size: 12px; text-align: center; } .footer a { - color: #b0adc5; + color: #a1a1aa; text-decoration: underline; } @@ -191,13 +197,13 @@ img { } .table th { - border-bottom: 1px solid #edeff2; + border-bottom: 1px solid #e4e4e7; margin: 0; padding-bottom: 8px; } .table td { - color: #74787e; + color: #52525b; font-size: 15px; line-height: 18px; margin: 0; @@ -233,46 +239,46 @@ img { .button-blue, .button-primary { - background-color: #2d3748; - border-bottom: 8px solid #2d3748; - border-left: 18px solid #2d3748; - border-right: 18px solid #2d3748; - border-top: 8px solid #2d3748; + background-color: #18181b; + border-bottom: 8px solid #18181b; + border-left: 18px solid #18181b; + border-right: 18px solid #18181b; + border-top: 8px solid #18181b; } .button-green, .button-success { - background-color: #48bb78; - border-bottom: 8px solid #48bb78; - border-left: 18px solid #48bb78; - border-right: 18px solid #48bb78; - border-top: 8px solid #48bb78; + background-color: #16a34a; + border-bottom: 8px solid #16a34a; + border-left: 18px solid #16a34a; + border-right: 18px solid #16a34a; + border-top: 8px solid #16a34a; } .button-red, .button-error { - background-color: #e53e3e; - border-bottom: 8px solid #e53e3e; - border-left: 18px solid #e53e3e; - border-right: 18px solid #e53e3e; - border-top: 8px solid #e53e3e; + background-color: #dc2626; + border-bottom: 8px solid #dc2626; + border-left: 18px solid #dc2626; + border-right: 18px solid #dc2626; + border-top: 8px solid #dc2626; } /* Panels */ .panel { - border-left: #2d3748 solid 4px; + border-left: #18181b solid 4px; margin: 21px 0; } .panel-content { - background-color: #edf2f7; - color: #718096; + background-color: #fafafa; + color: #52525b; padding: 16px; } .panel-content p { - color: #718096; + color: #52525b; } .panel-item { diff --git a/tests/Integration/Mail/Fixtures/layout-with-head.blade.php b/tests/Integration/Mail/Fixtures/layout-with-head.blade.php new file mode 100644 index 0000000000..b70b85635a --- /dev/null +++ b/tests/Integration/Mail/Fixtures/layout-with-head.blade.php @@ -0,0 +1,11 @@ + + + + + +# My basic content + +### Order details + +Your order has shipped. + diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php index fb57321a1b..952bbbcd2d 100644 --- a/tests/Integration/Mail/SendingMarkdownMailTest.php +++ b/tests/Integration/Mail/SendingMarkdownMailTest.php @@ -64,6 +64,19 @@ public function testMarkdownMailRendersWithANullApplicationUrl(): void $this->assertMatchesRegularExpression('/Example App:\s*(?:\r?\n|$)/', $text); } + public function testMarkdownLayoutRendersHeadSlotLocaleAndStyles(): void + { + $html = (new Mailable)->markdown('layout-with-head')->locale('pt_BR')->render(); + + $this->assertStringContainsString('lang="pt-BR"', $html); + $this->assertMatchesRegularExpression( + '~.*.*~s', + $html + ); + $this->assertMatchesRegularExpression('~]*style="[^"]*text-align: start;~', $html); + $this->assertMatchesRegularExpression('~]*style="[^"]*text-align: start;~', $html); + } + public function testMailMayHaveSpecificTextView(): void { $mailable = new MarkdownBasicMailableWithTextView; From 4e83234c73e77f0b329f07bffb188bce18d7feb7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:10:25 +0000 Subject: [PATCH 12/23] Complete queue identity coverage and batch callback types Complete the remaining test and documentation coverage for Laravel's custom job identity support, and restore the callable contracts on batch test fakes. The shared queue documentation keeps these related updates in one commit. Port the current strict unique-lock assertions and correct an upstream test that duplicates the display-name-with-ID case instead of checking a name without an ID. Retain all existing tests, type the test methods and helper, and correct the stale model annotation. Document displayName for unique and debounced jobs, overlap prevention and exception throttling. Clarify the class fallback and shared/custom-key behavior. Existing hashed lock keys, owner-aware lock handling and the native rate limiter's single physical-key hash remain unchanged. Annotate assertion callbacks with PendingBatchFake so both fake-specific and parent PendingBatch callbacks are accepted. Upstream's parent-only callable contract rejects its own fake-specific usage. Chained assertions retain PendingBatch. Correct the hasJobs documentation examples to name the fake that owns the method, and regenerate the Bus facade's batch collection generic. Native source signatures and runtime behavior are unchanged. Upstream source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. PRs and related current behavior: https://github.com/laravel/framework/pull/57499 https://github.com/laravel/framework/pull/59141 https://github.com/laravel/framework/pull/58070 https://github.com/laravel/framework/pull/58606 https://github.com/laravel/framework/pull/58659 Validation: UniqueJobTest and SupportTestingBusFakeTest pass. Full PHPStan source/type checks, focused callback and collection-type verification, formatting, Bus facade lint and diff checks pass. --- src/docs/queues.md | 29 +++++++++-- src/support/src/Facades/Bus.php | 4 +- src/support/src/Testing/Fakes/BusFake.php | 7 +++ .../Testing/Fakes/ChainedBatchTruthTest.php | 2 +- tests/Integration/Queue/UniqueJobTest.php | 49 ++++++++++--------- 5 files changed, 60 insertions(+), 31 deletions(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index d0ef346183..b0301791ee 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -518,6 +518,23 @@ class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique > [!NOTE] > If you only need to limit the concurrent processing of a job, use the [WithoutOverlapping](/docs/{{version}}/queues#preventing-job-overlaps) job middleware instead. + +#### Custom Job Names + +By default, Hypervel identifies jobs by their class name. You may define a `displayName` method on your job to provide a custom name: + +```php +/** + * Get the display name for the job. + */ +public function displayName(): string +{ + return 'search-index-updates'; +} +``` + +This name is also used to identify [unique jobs](#unique-jobs), [debounced jobs](#debounced-jobs), and jobs using the [WithoutOverlapping](#preventing-job-overlaps) or [ThrottlesExceptions](#throttling-exceptions) middleware. The `uniqueId` and `debounceId` values still distinguish jobs with the same name. The `WithoutOverlapping::shared` and `ThrottlesExceptions::by` methods may be used to override this grouping. + ### Debounced Jobs @@ -850,7 +867,7 @@ public function middleware(): array #### Sharing Lock Keys Across Job Classes -By default, the `WithoutOverlapping` middleware will only prevent overlapping jobs of the same class. So, although two different job classes may use the same lock key, they will not be prevented from overlapping. However, you can instruct Hypervel to apply the key across job classes using the `shared` method: +By default, the `WithoutOverlapping` middleware groups jobs by their [custom display name](#custom-job-names), or by their class name when no custom name is defined. Jobs in different groups may overlap even when they use the same lock key. However, you can instruct Hypervel to apply the key across job classes using the `shared` method: ```php use Hypervel\Queue\Middleware\WithoutOverlapping; @@ -958,7 +975,7 @@ return [(new ThrottlesExceptions(10, 5 * 60))->backoff( The middleware's `backoff` method controls the ordinary queue retry delay after an individual exception. It is separate from the rate limiter's [exponential backoff policy](/docs/{{version}}/rate-limiting#exponential-backoff). -Internally, this middleware uses Hypervel's rate limiter, and the job's display name is used as the rate limit key. You may override this key by calling the `by` method when attaching the middleware to your job. This may be useful if you have multiple jobs interacting with the same third-party service and would like them to share a common throttling bucket: +This middleware uses Hypervel's rate limiter, and the job's class name or [custom display name](#custom-job-names) is used as the rate limit key. You may override this key by calling the `by` method when attaching the middleware to your job. This may be useful if you have multiple jobs interacting with the same third-party service and would like them to share a common throttling bucket: ```php use Hypervel\Queue\Middleware\ThrottlesExceptions; @@ -3639,7 +3656,7 @@ Bus::assertChained([ ### Testing Job Batches -The `Bus` facade's `assertBatched` method may be used to assert that a [batch of jobs](/docs/{{version}}/queues#job-batching) was dispatched. The closure given to the `assertBatched` method receives an instance of `Hypervel\Bus\PendingBatch`, which may be used to inspect the jobs within the batch: +The `Bus` facade's `assertBatched` method may be used to assert that a [batch of jobs](/docs/{{version}}/queues#job-batching) was dispatched. The closure given to the `assertBatched` method receives an instance of `Hypervel\Support\Testing\Fakes\PendingBatchFake`, which extends `Hypervel\Bus\PendingBatch` and may be used to inspect the jobs within the batch: ```php use Hypervel\Bus\PendingBatch; @@ -3668,7 +3685,9 @@ Bus::assertBatched([ The `hasJobs` method may be used on the pending batch to verify that the batch contains the expected jobs. The method accepts an array of job instances, class names, or closures: ```php -Bus::assertBatched(function (PendingBatch $batch) { +use Hypervel\Support\Testing\Fakes\PendingBatchFake; + +Bus::assertBatched(function (PendingBatchFake $batch) { return $batch->hasJobs([ new ProcessCsvRow(row: 1), new ProcessCsvRow(row: 2), @@ -3680,7 +3699,7 @@ Bus::assertBatched(function (PendingBatch $batch) { When using closures, the closure will receive the job instance. The expected job type will be inferred from the closure's type hint: ```php -Bus::assertBatched(function (PendingBatch $batch) { +Bus::assertBatched(function (PendingBatchFake $batch) { return $batch->hasJobs([ fn (ProcessCsvRow $job) => $job->row === 1, fn (ProcessCsvRow $job) => $job->row === 2, diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php index 8b52094110..3a73874118 100644 --- a/src/support/src/Facades/Bus.php +++ b/src/support/src/Facades/Bus.php @@ -28,7 +28,7 @@ * @method static \Hypervel\Bus\Dispatcher withDispatchingAfterResponses() * @method static \Hypervel\Bus\Dispatcher withoutDispatchingAfterResponses() * @method static void assertBatchCount(int $count) - * @method static void assertBatched(callable|array $callback) + * @method static void assertBatched(array|callable $callback) * @method static void assertChained(array $expectedChain) * @method static void assertDispatched(\Closure|string $command, callable|int|null $callback = null) * @method static void assertDispatchedAfterResponse(\Closure|string $command, callable|int|null $callback = null) @@ -45,7 +45,7 @@ * @method static void assertNothingChained() * @method static void assertNothingDispatched() * @method static void assertNothingPlaced() - * @method static \Hypervel\Support\Collection batched(callable $callback) + * @method static \Hypervel\Support\Collection batched(callable $callback) * @method static \Hypervel\Support\Testing\Fakes\ChainedBatchTruthTest chainedBatch(\Closure $callback) * @method static \Hypervel\Support\Collection dispatched(string $command, callable|null $callback = null) * @method static \Hypervel\Support\Collection dispatchedAfterResponse(string $command, callable|null $callback = null) diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php index 4b57859bb4..77bdb4f696 100644 --- a/src/support/src/Testing/Fakes/BusFake.php +++ b/src/support/src/Testing/Fakes/BusFake.php @@ -419,6 +419,8 @@ protected function assertDispatchedWithChainOfObjects(string $command, array $ex /** * Create a new assertion about a chained batch. + * + * @param Closure(PendingBatch): bool $callback */ public function chainedBatch(Closure $callback): ChainedBatchTruthTest { @@ -427,6 +429,8 @@ public function chainedBatch(Closure $callback): ChainedBatchTruthTest /** * Assert if a batch was dispatched based on a truth-test callback. + * + * @param array|(callable(PendingBatchFake): bool) $callback */ public function assertBatched(callable|array $callback): void { @@ -515,6 +519,9 @@ public function dispatchedAfterResponse(string $command, ?callable $callback = n /** * Get all of the pending batches matching a truth-test callback. + * + * @param callable(PendingBatchFake): bool $callback + * @return Collection */ public function batched(callable $callback): Collection { diff --git a/src/support/src/Testing/Fakes/ChainedBatchTruthTest.php b/src/support/src/Testing/Fakes/ChainedBatchTruthTest.php index 35c3c7765b..9d30173cf2 100644 --- a/src/support/src/Testing/Fakes/ChainedBatchTruthTest.php +++ b/src/support/src/Testing/Fakes/ChainedBatchTruthTest.php @@ -12,7 +12,7 @@ class ChainedBatchTruthTest /** * Create a new truth test instance. * - * @param Closure $callback the underlying truth test + * @param Closure(PendingBatch): bool $callback the underlying truth test */ public function __construct( protected Closure $callback diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 6e0e9931a9..c589ea36a7 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -41,7 +41,7 @@ protected function defineEnvironment(ApplicationContract $app): void $config->set('queue.default', env('QUEUE_CONNECTION', 'database')); } - public function testUniqueJobsAreNotDispatched() + public function testUniqueJobsAreNotDispatched(): void { Bus::fake(); @@ -63,7 +63,7 @@ public function testUniqueJobsAreNotDispatched() ); } - public function testUniqueJobWithViaDispatched() + public function testUniqueJobWithViaDispatched(): void { Bus::fake(); @@ -71,7 +71,7 @@ public function testUniqueJobWithViaDispatched() Bus::assertDispatched(UniqueViaJob::class); } - public function testLockIsReleasedForSuccessfulJobs() + public function testLockIsReleasedForSuccessfulJobs(): void { UniqueTestJob::$handled = false; dispatch($job = new UniqueTestJob); @@ -81,7 +81,7 @@ public function testLockIsReleasedForSuccessfulJobs() $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } - public function testLockIsReleasedForFailedJobs() + public function testLockIsReleasedForFailedJobs(): void { UniqueTestFailJob::$handled = false; @@ -95,7 +95,7 @@ public function testLockIsReleasedForFailedJobs() } } - public function testLockIsNotReleasedForJobRetries() + public function testLockIsNotReleasedForJobRetries(): void { $this->markTestSkippedWhenUsingSyncQueueDriver(); @@ -117,7 +117,7 @@ public function testLockIsNotReleasedForJobRetries() $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } - public function testLockIsNotReleasedForJobReleases() + public function testLockIsNotReleasedForJobReleases(): void { $this->markTestSkippedWhenUsingSyncQueueDriver(); @@ -138,7 +138,7 @@ public function testLockIsNotReleasedForJobReleases() $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } - public function testLockCanBeReleasedBeforeProcessing() + public function testLockCanBeReleasedBeforeProcessing(): void { $this->markTestSkippedWhenUsingSyncQueueDriver(); @@ -154,11 +154,11 @@ public function testLockCanBeReleasedBeforeProcessing() $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } - public function testLockIsReleasedOnModelNotFoundException() + public function testLockIsReleasedOnModelNotFoundException(): void { UniqueTestSerializesModelsJob::$handled = false; - /** @var \Illuminate\Foundation\Auth\User */ + /** @var User $user */ $user = UserFactory::new()->create(); $job = new UniqueTestSerializesModelsJob($user); @@ -176,7 +176,7 @@ public function testLockIsReleasedOnModelNotFoundException() } } - public function testQueueFakeReleasesUniqueJobLocksBetweenFakes() + public function testQueueFakeReleasesUniqueJobLocksBetweenFakes(): void { Queue::fake(); @@ -189,7 +189,7 @@ public function testQueueFakeReleasesUniqueJobLocksBetweenFakes() Queue::assertPushed(UniqueTestJob::class); } - public function testQueueFakePreservesUniqueJobLockWithinTest() + public function testQueueFakePreservesUniqueJobLockWithinTest(): void { Queue::fake(); @@ -199,12 +199,15 @@ public function testQueueFakePreservesUniqueJobLockWithinTest() Queue::assertPushedTimes(UniqueTestJob::class, 1); } - protected function getLockKey($job) + /** + * Get the unique lock key for the given job. + */ + protected function getLockKey(object|string $job): string { return 'laravel_unique_job:' . (is_string($job) ? $job : get_class($job)) . ':'; } - public function testLockUsesDisplayNameWhenAvailable() + public function testLockUsesDisplayNameWhenAvailable(): void { Bus::fake(); @@ -228,33 +231,33 @@ public function testLockUsesDisplayNameWhenAvailable() ); } - public function testUniqueLockCreatesKeyWithClassName() + public function testUniqueLockCreatesKeyWithClassName(): void { - $this->assertEquals( + $this->assertSame( 'laravel_unique_job:' . UniqueTestJob::class . ':', UniqueLock::getKey(new UniqueTestJob) ); } - public function testUniqueLockCreatesKeyWithIdAndClassName() + public function testUniqueLockCreatesKeyWithIdAndClassName(): void { - $this->assertEquals( + $this->assertSame( 'laravel_unique_job:' . UniqueIdTestJob::class . ':unique-id-1', UniqueLock::getKey(new UniqueIdTestJob) ); } - public function testUniqueLockCreatesKeyWithDisplayNameWhenAvailable() + public function testUniqueLockCreatesKeyWithDisplayNameWhenAvailable(): void { - $this->assertEquals( - 'laravel_unique_job:' . hash('xxh128', 'App\Actions\UniqueTestAction') . ':unique-id-2', - UniqueLock::getKey(new UniqueIdTestJobWithDisplayName) + $this->assertSame( + 'laravel_unique_job:' . hash('xxh128', 'App\Actions\UniqueTestAction') . ':', + UniqueLock::getKey(new UniqueTestJobWithDisplayName) ); } - public function testUniqueLockCreatesKeyWithIdAndDisplayNameWhenAvailable() + public function testUniqueLockCreatesKeyWithIdAndDisplayNameWhenAvailable(): void { - $this->assertEquals( + $this->assertSame( 'laravel_unique_job:' . hash('xxh128', 'App\Actions\UniqueTestAction') . ':unique-id-2', UniqueLock::getKey(new UniqueIdTestJobWithDisplayName) ); From f270463cca91da2487117ee72487a87d4d454bea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:22:13 +0000 Subject: [PATCH 13/23] Preserve unique job lock ownership across retries and child dispatches Port Laravel framework #60906, #61039 and #61234 from 13.x source 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/60906 https://github.com/laravel/framework/pull/61039 https://github.com/laravel/framework/pull/61234 Release an owned unique-until-processing lock when middleware allows a later attempt to execute, while preserving the first-attempt restriction for jobs without an owner token. Restore all six missing upstream tests for retries, successor locks, missing models, skipped events and rollback. The skipped event and rollback ownership guards already existed natively. Also fix two related Hypervel defects. Jobs without Queueable now recover their captured owner from the actual queued payload, including middleware failure and unnamed custom cache repositories. Keep cache and key resolution inside UniqueLock and retain Queueable property precedence. The optional owner and queued-job parameters were explicitly approved; ordinary job APIs are unchanged, while overrides of those two methods must match signatures. An ordinary child dispatch no longer copies its parent's unique lock metadata into its payload. Strip only the three lock fields inside the existing exception-safe Context scope and restore application context after payload hooks. Preserve the fast path without creating context or adding worker state, cache lookups or network calls. Verification: immediate changed-file PHPUnit runs, focused Queue/Bus/Event/ Context ParaTest suites, full source and type-fixture PHPStan, formatting and diff checks pass. Independent review additionally passed full Queue, Bus, Log and Events coverage and reproduced both adjacent fixes. Regression coverage checks successor-owner protection, nested missing-model cleanup, unnamed repositories and context restoration after a throwing payload hook. --- src/bus/src/UniqueLock.php | 6 +- src/queue/src/CallQueuedHandler.php | 48 +++- src/queue/src/Queue.php | 19 +- .../Integration/Queue/JobDispatchingTest.php | 52 ++++ tests/Integration/Queue/UniqueJobTest.php | 246 ++++++++++++++++++ .../Queue/UniqueUntilProcessingJobTest.php | 133 +++++++++- tests/Queue/CallQueuedHandlerTest.php | 65 +++++ 7 files changed, 554 insertions(+), 15 deletions(-) diff --git a/src/bus/src/UniqueLock.php b/src/bus/src/UniqueLock.php index 68f2e1fc35..43d68399c0 100644 --- a/src/bus/src/UniqueLock.php +++ b/src/bus/src/UniqueLock.php @@ -122,8 +122,10 @@ protected function acquireResolvedLock( /** * Release the lock for the given job. + * + * @param string $owner the captured lock owner for a job without Queueable state */ - public function release(mixed $job): void + public function release(mixed $job, string $owner = ''): void { $cache = method_exists($job, 'uniqueVia') ? ($job->uniqueVia() ?? $this->cache) @@ -131,7 +133,7 @@ public function release(mixed $job): void $owner = isset(class_uses_recursive($job)[Queueable::class]) ? $job->uniqueLockOwner - : ''; + : $owner; static::releaseOwned($cache, static::getKey($job), $owner); } diff --git a/src/queue/src/CallQueuedHandler.php b/src/queue/src/CallQueuedHandler.php index 7df51fc9c2..4854c3abb1 100644 --- a/src/queue/src/CallQueuedHandler.php +++ b/src/queue/src/CallQueuedHandler.php @@ -9,6 +9,7 @@ use Hypervel\Bus\Batchable; use Hypervel\Bus\BatchRepository; use Hypervel\Bus\DebounceLock; +use Hypervel\Bus\Queueable; use Hypervel\Bus\UniqueLock; use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Contracts\Cache\Factory as CacheFactory; @@ -74,7 +75,7 @@ public function call(Job $job, array $data): void } if (! $job->isReleased() && ! $this->commandShouldBeUniqueUntilProcessing($command)) { - $this->ensureUniqueJobLockIsReleased($command); + $this->ensureUniqueJobLockIsReleased($command, $job); } if (! $job->hasFailed() && ! $job->isReleased()) { @@ -122,13 +123,13 @@ protected function dispatchThroughMiddleware(Job $job, mixed $command): mixed ->send($command) ->through(array_merge(method_exists($command, 'middleware') ? $command->middleware() : [], $command->middleware ?? [])) ->finally(function ($command) use ($job, &$lockReleased) { - if (! $lockReleased && $this->commandShouldBeUniqueUntilProcessing($command) && ! $job->isReleased() && $job->attempts() <= 1) { /* @phpstan-ignore booleanNot.alwaysTrue ($lockReleased is set in then() which runs before finally()) */ - $this->ensureUniqueJobLockIsReleased($command); + if (! $lockReleased && $this->commandShouldBeUniqueUntilProcessing($command) && ! $job->isReleased() && $this->uniqueJobLockShouldBeReleased($job, $command)) { /* @phpstan-ignore booleanNot.alwaysTrue ($lockReleased is set in then() which runs before finally()) */ + $this->ensureUniqueJobLockIsReleased($command, $job); } }) ->then(function ($command) use ($job, &$lockReleased) { - if ($this->commandShouldBeUniqueUntilProcessing($command) && $job->attempts() <= 1) { - $this->ensureUniqueJobLockIsReleased($command); + if ($this->commandShouldBeUniqueUntilProcessing($command) && $this->uniqueJobLockShouldBeReleased($job, $command)) { + $this->ensureUniqueJobLockIsReleased($command, $job); $lockReleased = true; } @@ -201,13 +202,44 @@ protected function ensureSuccessfulBatchJobIsRecorded(mixed $command): void } } + /** + * Determine if the unique job lock should be released. + */ + protected function uniqueJobLockShouldBeReleased(Job $job, mixed $command): bool + { + // Middleware may retain the original lock for a retry. An owner token + // allows cleanup on that retry without releasing a newer dispatch's lock. + return $job->attempts() <= 1 || $this->getUniqueJobLockOwner($job, $command) !== ''; + } + + /** + * Resolve the unique lock owner carried by the command or its queued payload. + */ + protected function getUniqueJobLockOwner(Job $job, mixed $command): string + { + if (isset(class_uses_recursive($command)[Queueable::class])) { + return $command->uniqueLockOwner; + } + + // Jobs without Queueable carry ownership in their payload. Ambient + // context may have been replaced by a nested synchronous dispatch. + $owner = $job->payload()['illuminate:log:context']['hidden']['laravel_unique_job_lock_owner'] ?? null; + + return $owner === null ? '' : unserialize($owner, ['allowed_classes' => false]); + } + /** * Ensure the lock for a unique job is released. + * + * @param null|Job $job the queued job carrying ownership for commands without Queueable state */ - protected function ensureUniqueJobLockIsReleased(mixed $command): void + protected function ensureUniqueJobLockIsReleased(mixed $command, ?Job $job = null): void { if ($this->commandShouldBeUnique($command)) { - (new UniqueLock($this->container->make(Cache::class)))->release($command); + (new UniqueLock($this->container->make(Cache::class)))->release( + $command, + $job === null ? '' : $this->getUniqueJobLockOwner($job, $command), + ); } } @@ -361,7 +393,7 @@ public function failed(array $data, ?Throwable $e, string $uuid, ?Job $job = nul } if (! $this->commandShouldBeUniqueUntilProcessing($command)) { - $this->ensureUniqueJobLockIsReleased($command); + $this->ensureUniqueJobLockIsReleased($command, $job); } if ($command instanceof __PHP_Incomplete_Class) { diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 1027cba657..0dbe8934c8 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -14,6 +14,7 @@ use Hypervel\Contracts\Queue\ShouldBeEncrypted; use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\DatabaseTransactionsManager; +use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Queue\Attributes\Backoff; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\DeleteWhenMissingModels; @@ -198,11 +199,23 @@ protected function createObjectPayload(object $job, ?string $queue): array $uniqueJobMetadata = DispatchLockContext::peekPayloadMetadata($job); - $payload = $uniqueJobMetadata === null + $payload = $uniqueJobMetadata === null && (! ContextRepository::hasInstance() || ! ContextRepository::getInstance()->hasHidden('laravel_unique_job_key')) ? $this->withCreatePayloadHooks($queue, $payload) : Context::scope( - fn (): array => $this->withCreatePayloadHooks($queue, $payload), - hidden: $uniqueJobMetadata, + function () use ($uniqueJobMetadata, $queue, $payload): array { + if ($uniqueJobMetadata === null) { + // A child without its own lock must not inherit its parent's + // ownership and release that lock during missing-model cleanup. + Context::forgetHidden([ + 'laravel_unique_job_cache_store', + 'laravel_unique_job_key', + 'laravel_unique_job_lock_owner', + ]); + } + + return $this->withCreatePayloadHooks($queue, $payload); + }, + hidden: $uniqueJobMetadata ?? [], ); try { diff --git a/tests/Integration/Queue/JobDispatchingTest.php b/tests/Integration/Queue/JobDispatchingTest.php index 97da1c4d81..d9f6ced996 100644 --- a/tests/Integration/Queue/JobDispatchingTest.php +++ b/tests/Integration/Queue/JobDispatchingTest.php @@ -20,6 +20,7 @@ use Hypervel\Support\Facades\Config; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Tests\Integration\Queue\QueueTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; #[WithMigration] @@ -227,6 +228,57 @@ public function testPayloadHookFailureDoesNotLeakUniqueMetadataIntoTheNextPayloa ); } + #[DataProvider('payloadHookResults')] + public function testOrdinaryPayloadDoesNotInheritUniqueJobMetadata(bool $hookFails): void + { + config(['queue.default' => 'database']); + + $hidden = [ + 'persistent' => 'value', + 'laravel_unique_job_cache_store' => 'database', + 'laravel_unique_job_key' => 'laravel_unique_job:parent:', + 'laravel_unique_job_lock_owner' => 'parent-owner', + ]; + $context = ContextRepository::getInstance()->add('request_id', 'request')->addHidden($hidden); + $ordinaryPayload = null; + $failure = $hookFails ? new RuntimeException('Payload hook failed.') : null; + + Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload) use (&$ordinaryPayload, $failure): array { + $ordinaryPayload = $payload; + + if ($failure !== null) { + throw $failure; + } + + return []; + }); + + $caught = null; + + try { + Job::dispatch('ordinary'); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertSame(['persistent' => serialize('value')], $ordinaryPayload['illuminate:log:context']['hidden']); + $this->assertSame(['request_id' => serialize('request')], $ordinaryPayload['illuminate:log:context']['data']); + $this->assertSame($hidden, $context->allHidden()); + $this->assertSame(['request_id' => 'request'], $context->all()); + } + + /** + * Provide successful and failing payload hooks. + */ + public static function payloadHookResults(): array + { + return [ + 'successful hook' => [false], + 'failing hook' => [true], + ]; + } + public function testQueueMayBeNullForJobQueueingAndJobQueuedEvent(): void { Config::set('queue.default', 'database'); diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index c589ea36a7..966b71c953 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -16,9 +16,12 @@ use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Foundation\Auth\User; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\Events\UniqueJobSkipped; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\SerializesModels; use Hypervel\Support\Facades\Bus; +use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Queue; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Testbench\Factories\UserFactory; @@ -63,6 +66,26 @@ public function testUniqueJobsAreNotDispatched(): void ); } + public function testUniqueJobEmitsUniqueJobSkippedEventWhenAlreadyAcquired(): void + { + Bus::fake(); + + $skipped = []; + + Event::listen(UniqueJobSkipped::class, function (UniqueJobSkipped $event) use (&$skipped): void { + $skipped[] = $event->job; + }); + + UniqueTestJob::dispatch(); + + $this->assertSame([], $skipped); + + UniqueTestJob::dispatch(); + + $this->assertCount(1, $skipped); + $this->assertInstanceOf(UniqueTestJob::class, $skipped[0]); + } + public function testUniqueJobWithViaDispatched(): void { Bus::fake(); @@ -154,6 +177,41 @@ public function testLockCanBeReleasedBeforeProcessing(): void $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } + public function testRetryOfUniqueUntilProcessingJobDoesNotReleaseSubsequentLock(): void + { + $this->markTestSkippedWhenUsingSyncQueueDriver(); + + dispatch($job = new UniqueUntilProcessingRetryJob); + + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertTrue($job::$handled); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 60)->get()); + + UniqueUntilProcessingRetryJob::$handled = false; + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertTrue($job::$handled); + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + + public function testRetryOfOwnerlessUniqueUntilProcessingJobDoesNotReleaseSubsequentLock(): void + { + $this->markTestSkippedWhenUsingSyncQueueDriver(); + + dispatch($job = new OwnerlessUniqueUntilProcessingRetryJob); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 60)->get()); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + public function testLockIsReleasedOnModelNotFoundException(): void { UniqueTestSerializesModelsJob::$handled = false; @@ -176,6 +234,57 @@ public function testLockIsReleasedOnModelNotFoundException(): void } } + public function testModelNotFoundExceptionDoesNotReleaseSubsequentLock(): void + { + $this->markTestSkippedWhenUsingSyncQueueDriver(); + + /** @var User $user */ + $user = UserFactory::new()->create(); + $job = new UniqueTestSerializesModelsJob($user); + $cache = $this->app->get(Cache::class); + $lock = new UniqueLock($cache); + + dispatch($job); + + $lock->release($job); + + $replacement = new UniqueTestSerializesModelsJob($user); + $this->assertTrue($lock->acquire($replacement)); + + $user->delete(); + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertFalse($cache->lock($this->getLockKey($job), 10)->get()); + + $lock->release($replacement); + } + + public function testMissingModelInOrdinaryChildDoesNotReleaseParentUniqueLock(): void + { + $this->markTestSkippedWhenUsingSyncQueueDriver(); + + NestedUniqueParentJob::$dispatchedChild = false; + + /** @var User $user */ + $user = UserFactory::new()->create(); + + dispatch($job = new NestedUniqueParentJob); + + $cache = $this->app->get(Cache::class); + $this->assertFalse($cache->lock($this->getLockKey($job), 10)->get()); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertTrue(NestedUniqueParentJob::$dispatchedChild); + $this->assertFalse($cache->lock($this->getLockKey($job), 10)->get()); + + $user->delete(); + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertSame(1, Queue::size()); + $this->assertFalse($cache->lock($this->getLockKey($job), 10)->get()); + } + public function testQueueFakeReleasesUniqueJobLocksBetweenFakes(): void { Queue::fake(); @@ -199,6 +308,26 @@ public function testQueueFakePreservesUniqueJobLockWithinTest(): void Queue::assertPushedTimes(UniqueTestJob::class, 1); } + public function testRolledBackPushDoesNotReleaseAnotherDispatchesUniqueLock(): void + { + $this->markTestSkippedWhenUsingSyncQueueDriver(); + + dispatch($job = new UniqueTestAfterCommitJob); + + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + + try { + DB::transaction(function (): never { + Queue::push(new UniqueTestAfterCommitJob); + + throw new Exception('Rollback.'); + }); + } catch (Exception) { + } + + $this->assertFalse($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + /** * Get the unique lock key for the given job. */ @@ -318,6 +447,47 @@ class UniqueUntilStartTestJob extends UniqueTestJob implements ShouldBeUniqueUnt public int $tries = 2; } +class UniqueUntilProcessingRetryJob implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + use InteractsWithQueue; + use Queueable; + use Dispatchable; + + public int $tries = 2; + + public static bool $handled = false; + + /** + * Handle the job. + */ + public function handle(): void + { + static::$handled = true; + + if ($this->attempts() === 1) { + throw new Exception('First attempt failure.'); + } + } +} + +class OwnerlessUniqueUntilProcessingRetryJob implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + use InteractsWithQueue; + use Dispatchable; + + public int $tries = 2; + + /** + * Handle the job. + */ + public function handle(): void + { + if ($this->attempts() === 1) { + throw new Exception('First attempt failure.'); + } + } +} + class UniqueTestSerializesModelsJob extends UniqueTestJob { use SerializesModels; @@ -365,3 +535,79 @@ public function displayName(): string return 'App\Actions\UniqueTestAction'; } } + +class UniqueTestAfterCommitJob implements ShouldQueue, ShouldBeUnique +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + + /** + * Create a job that dispatches after commit. + */ + public function __construct() + { + $this->afterCommit = true; + } + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + +class NestedUniqueParentJob implements ShouldQueue, ShouldBeUnique +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + + public int $tries = 3; + + public static bool $dispatchedChild = false; + + /** + * Get the lifetime of the unique lock. + */ + public function uniqueFor(): int + { + return 300; + } + + /** + * Dispatch a child while retaining this job's lock for a retry. + */ + public function handle(): void + { + static::$dispatchedChild = true; + + NestedOrdinaryChildJob::dispatch(User::query()->firstOrFail()); + + $this->release(120); + } +} + +class NestedOrdinaryChildJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; + use SerializesModels; + + public bool $deleteWhenMissingModels = true; + + /** + * Create a child job containing a model. + */ + public function __construct(public User $user) + { + } + + /** + * Handle the job. + */ + public function handle(): void + { + } +} diff --git a/tests/Integration/Queue/UniqueUntilProcessingJobTest.php b/tests/Integration/Queue/UniqueUntilProcessingJobTest.php index c51c60cc92..c4a6ca416e 100644 --- a/tests/Integration/Queue/UniqueUntilProcessingJobTest.php +++ b/tests/Integration/Queue/UniqueUntilProcessingJobTest.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Integration\Queue\UniqueUntilProcessingJobTest; use Hypervel\Bus\Queueable; +use Hypervel\Cache\Repository; +use Hypervel\Container\Container; +use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Hypervel\Contracts\Queue\ShouldQueue; @@ -13,6 +16,7 @@ use Hypervel\Support\Facades\DB; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Tests\Integration\Queue\QueueTestCase; +use PHPUnit\Framework\Attributes\DataProvider; #[WithMigration] #[WithMigration('cache')] @@ -28,7 +32,7 @@ protected function defineEnvironment(ApplicationContract $app): void $config->set('cache.default', 'database'); } - public function testShouldBeUniqueUntilProcessingReleasesLockWhenJobIsReleasedByAMiddleware() + public function testShouldBeUniqueUntilProcessingReleasesLockWhenJobIsReleasedByAMiddleware(): void { // Job that does not release and gets processed UniqueTestJobThatDoesNotRelease::dispatch(); @@ -53,6 +57,52 @@ public function testShouldBeUniqueUntilProcessingReleasesLockWhenJobIsReleasedBy UniqueUntilProcessingJobThatReleases::dispatch(); $this->assertDatabaseCount('jobs', 1); } + + public function testShouldBeUniqueUntilProcessingReleasesLockWhenLaterAttemptIsProcessed(): void + { + UniqueUntilProcessingJobThatReleasesOnce::dispatch(); + + $this->assertNotNull(DB::table('cache_locks')->first()); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertFalse(UniqueUntilProcessingJobThatReleasesOnce::$handled); + $this->assertNotNull(DB::table('cache_locks')->first()); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertTrue(UniqueUntilProcessingJobThatReleasesOnce::$handled); + $this->assertNull(DB::table('cache_locks')->first()); + } + + #[DataProvider('ownerlessCacheRepositories')] + public function testJobWithoutQueueableReleasesItsLockWhenLaterAttemptIsProcessed(bool $customCache): void + { + UniqueUntilProcessingJobWithoutQueueable::dispatch($customCache); + + $this->assertNotNull(DB::table('cache_locks')->first()); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertFalse(UniqueUntilProcessingJobWithoutQueueable::$handled); + $this->assertNotNull(DB::table('cache_locks')->first()); + + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertTrue(UniqueUntilProcessingJobWithoutQueueable::$handled); + $this->assertNull(DB::table('cache_locks')->first()); + } + + /** + * Provide cache repositories for jobs without Queueable state. + */ + public static function ownerlessCacheRepositories(): array + { + return [ + 'default repository' => [false], + 'unnamed repository' => [true], + ]; + } } class UniqueTestJobThatDoesNotRelease implements ShouldQueue, ShouldBeUniqueUntilProcessing @@ -82,7 +132,7 @@ class UniqueUntilProcessingJobThatReleases extends UniqueTestJobThatDoesNotRelea public function middleware(): array { return [ - function ($job) { + function (self $job): mixed { static::$released = true; return $job->release(30); @@ -95,3 +145,82 @@ public function uniqueId(): int return 100; } } + +class UniqueUntilProcessingJobThatReleasesOnce extends UniqueTestJobThatDoesNotRelease +{ + public int $tries = 2; + + /** + * Get the job middleware. + */ + public function middleware(): array + { + return [ + function (self $job, callable $next): mixed { + if ($job->attempts() === 1) { + return $job->release(); + } + + return $next($job); + }, + ]; + } + + /** + * Get the unique identifier for the job. + */ + public function uniqueId(): int + { + return 200; + } +} + +class UniqueUntilProcessingJobWithoutQueueable implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + use Dispatchable; + use InteractsWithQueue; + + public int $tries = 2; + + public static bool $handled = false; + + /** + * Create a job with the selected cache repository. + */ + public function __construct(public bool $customCache) + { + static::$handled = false; + } + + /** + * Resolve an unnamed repository when requested. + */ + public function uniqueVia(): ?Cache + { + return $this->customCache ? new Repository(Container::getInstance()->make(Cache::class)->getStore()) : null; + } + + /** + * Release the first attempt before invoking the handler. + */ + public function middleware(): array + { + return [ + function (self $job, callable $next): mixed { + if ($job->attempts() === 1) { + return $job->release(); + } + + return $next($job); + }, + ]; + } + + /** + * Handle the job. + */ + public function handle(): void + { + static::$handled = true; + } +} diff --git a/tests/Queue/CallQueuedHandlerTest.php b/tests/Queue/CallQueuedHandlerTest.php index 97035a659c..48cafef059 100644 --- a/tests/Queue/CallQueuedHandlerTest.php +++ b/tests/Queue/CallQueuedHandlerTest.php @@ -8,6 +8,9 @@ use Hypervel\Bus\BatchRepository; use Hypervel\Bus\Dispatcher as ConcreteBusDispatcher; use Hypervel\Bus\Queueable; +use Hypervel\Bus\UniqueLock; +use Hypervel\Cache\Repository; +use Hypervel\Cache\WorkerArrayStore; use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcher; use Hypervel\Contracts\Cache\Lock; @@ -23,6 +26,7 @@ use Hypervel\Queue\Jobs\FakeJob; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use RuntimeException; use Swoole\Coroutine\Channel; @@ -150,6 +154,55 @@ public function testUniqueUntilProcessingRetryDoesNotReleaseLockAgain(): void $handler->call($job, ['command' => $serialized]); } + #[DataProvider('middlewareRetryLockOwners')] + public function testMiddlewareFailureOnRetryReleasesOnlyItsOwnLockWithoutJobTraits(bool $replacementOwner): void + { + $command = new CallQueuedHandlerTestUniqueJobWithFailingMiddleware; + $cache = new Repository(new WorkerArrayStore); + $lock = $cache->lock(UniqueLock::getKey($command), 60, $replacementOwner ? 'replacement-owner' : 'original-owner'); + $this->assertTrue($lock->get()); + + $failure = new RuntimeException('Middleware failed.'); + $container = m::mock(ContainerContract::class); + $container->shouldReceive('make')->with(Cache::class)->andReturn($cache); + $container->shouldReceive('make')->with('failing.middleware')->once() + ->andReturn(static fn (): never => throw $failure); + + $dispatcher = m::mock(BusDispatcher::class); + $dispatcher->shouldReceive('dispatchNow')->never(); + + $job = m::mock(Job::class); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('attempts')->andReturn(2); + $job->shouldReceive('payload')->andReturn([ + 'illuminate:log:context' => [ + 'hidden' => ['laravel_unique_job_lock_owner' => serialize('original-owner')], + ], + ]); + + $handler = new CallQueuedHandler($dispatcher, $container); + + try { + $handler->call($job, ['command' => serialize($command)]); + $this->fail('The middleware exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame($replacementOwner, $lock->isOwnedByCurrentProcess()); + } + + /** + * Provide original and replacement lock owners. + */ + public static function middlewareRetryLockOwners(): array + { + return [ + 'original owner' => [false], + 'replacement owner' => [true], + ]; + } + public function testHandleModelNotFoundFailsJobWhenDeleteWhenMissingModelsIsFalse(): void { $container = m::mock(ContainerContract::class); @@ -400,6 +453,18 @@ public function handle(): void } } +class CallQueuedHandlerTestUniqueJobWithFailingMiddleware implements ShouldBeUniqueUntilProcessing, ShouldQueue +{ + public array $middleware = ['failing.middleware']; + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + class CallQueuedHandlerTestRegularJob implements ShouldQueue { use InteractsWithQueue; From b6a1e99e38009184851e9d62d91f084d28ccaab8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:18:28 +0000 Subject: [PATCH 14/23] Tighten queue worker interruption polling coverage Port the current Laravel tests for disabling restart and pause polling: https://github.com/laravel/framework/pull/57975 Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Use the upstream Cache facade setup while asserting Hypervel's actual store, restart, global-pause and batched queue-pause call counts. Forbid obsolete driver calls after installing the facade mock. Preserve the existing coroutine worker behavior and job completion assertions. The changed test file, affected console tests, PHPStan and formatting pass. The change received independent review before committing. --- tests/Integration/Queue/WorkCommandTest.php | 24 ++++++++------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index fd3ff99cb0..14963d2d98 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Integration\Queue\WorkCommandTest; use Hypervel\Bus\Queueable; -use Hypervel\Cache\CacheManager; use Hypervel\Cache\Repository; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Queue\ShouldQueue; @@ -15,6 +14,7 @@ use Hypervel\Queue\Worker; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Artisan; +use Hypervel\Support\Facades\Cache; use Hypervel\Support\Facades\Exceptions; use Hypervel\Support\Facades\Queue; use Hypervel\Testbench\Attributes\WithMigration; @@ -301,16 +301,13 @@ public function testDisableLastRestartCheck(): void $cache = m::mock(Repository::class); $cache->shouldNotReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY); - $cache->shouldReceive('get')->with('illuminate:queues:paused', false)->andReturn(false); - $cache->shouldReceive('many') + $cache->expects('get')->with('illuminate:queues:paused', false)->andReturn(false); + $cache->expects('many') ->with(['illuminate:queue:paused:database:default']) ->andReturn(['illuminate:queue:paused:database:default' => false]); - $cacheManager = m::mock(CacheManager::class); - $cacheManager->shouldReceive('driver')->andReturn($cache); - $cacheManager->shouldReceive('store')->andReturn($cache); - - $this->app->instance('cache', $cacheManager); + Cache::expects('store')->twice()->andReturn($cache); + Cache::shouldNotReceive('driver'); Queue::push(new FirstJob); @@ -325,7 +322,7 @@ public function testDisableLastRestartCheck(): void Worker::$restartable = true; } - public function testDisablePauseQueueCheck() + public function testDisablePauseQueueCheck(): void { $this->markTestSkippedWhenUsingQueueDrivers(['redis', 'beanstalkd']); @@ -333,14 +330,11 @@ public function testDisablePauseQueueCheck() $cache = m::mock(Repository::class); - $cache->shouldReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY)->andReturn(null); + $cache->expects('get')->twice()->with(Worker::RESTART_SIGNAL_CACHE_KEY)->andReturn(null); $cache->shouldNotReceive('many'); - $cacheManager = m::mock(CacheManager::class); - $cacheManager->shouldReceive('driver')->andReturn($cache); - $cacheManager->shouldReceive('store')->andReturn($cache); - - $this->app->instance('cache', $cacheManager); + Cache::expects('store')->andReturn($cache); + Cache::shouldNotReceive('driver'); Queue::push(new FirstJob); From 648e4d3ac3350c62e0515a8a2032de05387b2baf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:18:28 +0000 Subject: [PATCH 15/23] Port command prohibitions and clearing multiple queues Complete the command updates from current Laravel 13.x: https://github.com/laravel/framework/pull/57988 https://github.com/laravel/framework/pull/60430 https://github.com/laravel/framework/pull/60215 https://github.com/laravel/framework/pull/60873 https://github.com/laravel/framework/pull/60224 https://github.com/laravel/framework/pull/44927 https://github.com/laravel/framework/pull/58345 https://github.com/laravel/framework/pull/61004 Source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Allow applications to prohibit cache:clear, queue:clear and queue:flush during boot. Check prohibition before any clearing or force option and register the flags in the existing test cleanup registry. Document these public controls alongside the existing key:generate prohibition. Clear comma-separated queues with the current upstream output and all five upstream test cases. Preserve Hypervel's explicit zero/default name handling and correct upstream filtering and loose deduplication: queue names 0, 01 and 1 are distinct and must each be cleared exactly once. Retain pooled connection forwarding without extra driver operations. Return failure when key generation is prohibited, confirmation is declined, or the environment key cannot be replaced. These paths formerly reported an error or refusal but exited successfully. Preserve atomic publication, file permissions, config consistency and IO exceptions. Return explicit success for display and successful publication, removing the return-of-void suppression. Failed-job flushing also returns explicit failure on prohibition and success after either retention branch. Complete the collection reduction update in custom-pivot detachment while preserving pivot retrieval, constraints, delete events and query counts. Keep existing pivot regressions instead of adding duplicate coverage. Extend existing cache and key-generation tests, remove an unused cache test stub, and add focused failed-job flush and numeric-queue regressions. The production confirmation test uses the existing prompt fallback. Changed-file tests, affected ParaTest suites, full source/type PHPStan, formatting and diff checks pass. Independent review also verified the Database, Cache, Queue, Console and Encryption suites before signoff. --- src/cache/src/Console/ClearCommand.php | 7 + .../Concerns/InteractsWithPivotTable.php | 11 +- src/docs/cache.md | 8 ++ src/docs/encryption.md | 8 ++ src/docs/queues.md | 22 +++ .../src/Commands/KeyGenerateCommand.php | 12 +- src/queue/src/Console/ClearCommand.php | 37 +++-- src/queue/src/Console/FlushFailedCommand.php | 17 ++- .../src/PHPUnit/AfterEachTestSubscriber.php | 3 + tests/Cache/ClearCommandTest.php | 55 ++++++-- .../Encryption/KeyGenerateCommandTest.php | 26 +++- tests/Queue/QueueClearCommandTest.php | 131 ++++++++++++++++++ tests/Queue/QueueFlushFailedCommandTest.php | 59 ++++++++ 13 files changed, 349 insertions(+), 47 deletions(-) create mode 100644 tests/Queue/QueueClearCommandTest.php create mode 100644 tests/Queue/QueueFlushFailedCommandTest.php diff --git a/src/cache/src/Console/ClearCommand.php b/src/cache/src/Console/ClearCommand.php index 19948814e0..9f026098d5 100644 --- a/src/cache/src/Console/ClearCommand.php +++ b/src/cache/src/Console/ClearCommand.php @@ -7,6 +7,7 @@ use BadMethodCallException; use Hypervel\Cache\CacheManager; use Hypervel\Console\Command; +use Hypervel\Console\Prohibitable; use Hypervel\Contracts\Cache\Repository; use Hypervel\Filesystem\Filesystem; use Symfony\Component\Console\Attribute\AsCommand; @@ -16,6 +17,8 @@ #[AsCommand(name: 'cache:clear')] class ClearCommand extends Command { + use Prohibitable; + /** * The console command name. */ @@ -41,6 +44,10 @@ public function __construct( */ public function handle(): int { + if ($this->isProhibited()) { + return self::FAILURE; + } + if ($this->option('locks')) { return $this->clearLocks(); } diff --git a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php index be2ca7f062..6bcfb88313 100644 --- a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -491,15 +491,8 @@ public function detachOrFail(mixed $ids = null, bool $touch = true): int */ protected function detachUsingCustomClass(mixed $ids): int { - $results = 0; - - $records = $this->getCurrentlyAttachedPivotsForIds($ids); - - foreach ($records as $record) { - $results += $record->delete(); - } - - return $results; + return $this->getCurrentlyAttachedPivotsForIds($ids) + ->reduce(fn (int $carry, Model $record): int => $carry + $record->delete(), 0); } /** diff --git a/src/docs/cache.md b/src/docs/cache.md index 09c4a89c3d..7d87226339 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -1135,6 +1135,14 @@ Hypervel includes several Artisan commands for working with cache stores: +To prevent `cache:clear` from running in production, call the command's `prohibit` method from your `AppServiceProvider`'s `boot` method. This also prevents clearing locks: + +```php +use Hypervel\Cache\Console\ClearCommand; + +ClearCommand::prohibit($this->app->isProduction()); +``` + ## Events diff --git a/src/docs/encryption.md b/src/docs/encryption.md index 8fa515132f..af9efb4460 100644 --- a/src/docs/encryption.md +++ b/src/docs/encryption.md @@ -17,6 +17,14 @@ Before using Hypervel's encrypter, you must set the `key` configuration option i Hypervel supports `AES-128-CBC`, `AES-256-CBC`, `AES-128-GCM`, and `AES-256-GCM`. By default, Hypervel uses the `AES-256-CBC` cipher. +To prevent `key:generate` from running in production, call the command's `prohibit` method from your `AppServiceProvider`'s `boot` method. This also prevents using `--force` or `--show`: + +```php +use Hypervel\Encryption\Commands\KeyGenerateCommand; + +KeyGenerateCommand::prohibit($this->app->isProduction()); +``` + ### Gracefully Rotating Encryption Keys diff --git a/src/docs/queues.md b/src/docs/queues.md index b0301791ee..6e7d991bc5 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -3208,6 +3208,14 @@ The `queue:flush` command removes all failed job records from your queue, no mat php artisan queue:flush --hours=48 ``` +To prevent `queue:flush` from running in production, call the command's `prohibit` method from your `AppServiceProvider`'s `boot` method: + +```php +use Hypervel\Queue\Console\FlushFailedCommand; + +FlushFailedCommand::prohibit($this->app->isProduction()); +``` + ### Ignoring Missing Models @@ -3309,6 +3317,20 @@ You may also provide the `connection` argument and `queue` option to delete jobs php artisan queue:clear redis --queue=emails ``` +To clear multiple queues, provide a comma-separated list of queue names: + +```shell +php artisan queue:clear redis --queue=high,low,emails +``` + +To prevent `queue:clear` from running in production, call the command's `prohibit` method from your `AppServiceProvider`'s `boot` method. Prohibited commands cannot be run using `--force`: + +```php +use Hypervel\Queue\Console\ClearCommand; + +ClearCommand::prohibit($this->app->isProduction()); +``` + > [!WARNING] > Clearing jobs from queues is only available for the SQS, Redis, and database queue drivers. In addition, the SQS message deletion process takes up to 60 seconds, so jobs sent to the SQS queue up to 60 seconds after you clear the queue might also be deleted. diff --git a/src/encryption/src/Commands/KeyGenerateCommand.php b/src/encryption/src/Commands/KeyGenerateCommand.php index c63c88e5c8..8dc18a66dc 100644 --- a/src/encryption/src/Commands/KeyGenerateCommand.php +++ b/src/encryption/src/Commands/KeyGenerateCommand.php @@ -34,28 +34,32 @@ class KeyGenerateCommand extends Command /** * Execute the console command. */ - public function handle() + public function handle(): int { if ($this->isProhibited()) { - return; + return self::FAILURE; } $key = $this->generateRandomKey(); if ($this->option('show')) { - return $this->line('' . $key . ''); // @phpstan-ignore method.void + $this->line('' . $key . ''); + + return self::SUCCESS; } // Next, we will replace the application key in the environment file so it is // automatically setup for this developer. This key gets generated using a // secure random byte generator and is later base64 encoded for storage. if (! $this->setKeyInEnvironmentFile($key)) { - return; + return self::FAILURE; } $this->hypervel->make('config')->set('app.key', $key); $this->components->info('Application key set successfully.'); + + return self::SUCCESS; } /** diff --git a/src/queue/src/Console/ClearCommand.php b/src/queue/src/Console/ClearCommand.php index 9016a8b586..818e494502 100644 --- a/src/queue/src/Console/ClearCommand.php +++ b/src/queue/src/Console/ClearCommand.php @@ -6,8 +6,10 @@ use Hypervel\Console\Command; use Hypervel\Console\ConfirmableTrait; +use Hypervel\Console\Prohibitable; use Hypervel\Contracts\Queue\ClearableQueue; use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use ReflectionClass; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; @@ -17,6 +19,7 @@ class ClearCommand extends Command { use ConfirmableTrait; + use Prohibitable; /** * The console command name. @@ -26,15 +29,15 @@ class ClearCommand extends Command /** * The console command description. */ - protected string $description = 'Delete all of the jobs from the specified queue'; + protected string $description = 'Delete all of the jobs from the specified queues'; /** * Execute the console command. */ - public function handle(): ?int + public function handle(): int { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || ! $this->confirmToProceed()) { + return self::FAILURE; } $connection = $this->argument('connection'); @@ -50,17 +53,25 @@ public function handle(): ?int $queue = $this->hypervel->make('queue')->connection($connection); - if ($queue instanceof ClearableQueue) { - $count = $queue->clear($queueName); + if (! $queue instanceof ClearableQueue) { + $this->components->error('Clearing queues is not supported on [' . (new ReflectionClass($queue))->getShortName() . ']'); - $this->info('Cleared ' . $count . ' ' . Str::plural('job', $count) . ' from the [' . $queueName . '] queue'); - } else { - $this->error('Clearing queues is not supported on [' . (new ReflectionClass($queue))->getShortName() . ']'); - - return 1; + return self::FAILURE; } - return 0; + // Queue names such as "0", "01", and "1" are distinct identifiers. + $queues = (new Stringable($queueName))->explode(',') + ->map(static fn (string $queue): string => trim($queue)) + ->filter(static fn (string $queue): bool => $queue !== '') + ->uniqueStrict(); + + $count = $queues->reduce(fn (int $carry, string $name): int => $carry + $queue->clear($name), 0); + + $this->components->info( + sprintf('Cleared %s %s from the [%s] %s', $count, Str::plural('job', $count), $queues->implode(', '), Str::plural('queue', $queues->count())) + ); + + return self::SUCCESS; } /** @@ -91,7 +102,7 @@ protected function getArguments(): array protected function getOptions(): array { return [ - ['queue', null, InputOption::VALUE_OPTIONAL, 'The name of the queue to clear'], + ['queue', null, InputOption::VALUE_OPTIONAL, 'The names of the queues to clear'], ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], ]; diff --git a/src/queue/src/Console/FlushFailedCommand.php b/src/queue/src/Console/FlushFailedCommand.php index 8fe21c3f0f..a989d220c9 100644 --- a/src/queue/src/Console/FlushFailedCommand.php +++ b/src/queue/src/Console/FlushFailedCommand.php @@ -5,12 +5,15 @@ namespace Hypervel\Queue\Console; use Hypervel\Console\Command; +use Hypervel\Console\Prohibitable; use Hypervel\Queue\Failed\FailedJobProviderInterface; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'queue:flush')] class FlushFailedCommand extends Command { + use Prohibitable; + /** * The console command name. */ @@ -24,19 +27,25 @@ class FlushFailedCommand extends Command /** * Execute the console command. */ - public function handle() + public function handle(): int { + if ($this->isProhibited()) { + return self::FAILURE; + } + $hours = $this->option('hours'); $this->hypervel->make(FailedJobProviderInterface::class) ->flush($hours ? (int) $hours : null); if ($this->option('hours')) { - $this->info("All jobs that failed more than {$this->option('hours')} hours ago have been deleted successfully."); + $this->components->info("All jobs that failed more than {$this->option('hours')} hours ago have been deleted successfully."); - return; + return self::SUCCESS; } - $this->info('All failed jobs deleted successfully.'); + $this->components->info('All failed jobs deleted successfully.'); + + return self::SUCCESS; } } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index a5f3ae2910..a22cca8bcd 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -131,6 +131,7 @@ protected function flushFrameworkState(): void \Hypervel\Broadcasting\Broadcasters\Broadcaster::flushState(); \Hypervel\Bus\DispatchLockContext::flushState(); \Hypervel\Bus\PendingBatch::flushState(); + \Hypervel\Cache\Console\ClearCommand::flushState(); \Hypervel\Cache\Redis\Console\BenchmarkCommand::flushState(); \Hypervel\Cache\Redis\Console\DoctorCommand::flushState(); \Hypervel\Cache\Repository::flushState(); @@ -236,6 +237,8 @@ protected function flushFrameworkState(): void \Hypervel\Prompts\Prompt::flushState(); \Hypervel\Prompts\Terminal::flushState(); \Hypervel\Queue\Capsule\Manager::flushState(); + \Hypervel\Queue\Console\ClearCommand::flushState(); + \Hypervel\Queue\Console\FlushFailedCommand::flushState(); \Hypervel\Queue\Console\WorkCommand::flushState(); \Hypervel\Queue\Queue::flushState(); \Hypervel\Queue\Worker::flushState(); diff --git a/tests/Cache/ClearCommandTest.php b/tests/Cache/ClearCommandTest.php index 2be571590a..aa9498f05e 100644 --- a/tests/Cache/ClearCommandTest.php +++ b/tests/Cache/ClearCommandTest.php @@ -13,12 +13,14 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; class ClearCommandTest extends TestCase { - private ClearCommandTestStub $command; + private ClearCommand $command; private CacheManager|m\MockInterface $cacheManager; @@ -36,18 +38,48 @@ protected function setUp(): void $this->cacheManager = m::mock(CacheManager::class); $this->files = m::mock(Filesystem::class); $this->cacheRepository = m::mock(Repository::class); - $this->command = new ClearCommandTestStub($this->cacheManager, $this->files); + $this->command = new ClearCommand($this->cacheManager, $this->files); $this->command->setHypervel($app); } - public function testClearWithNoStoreArgument() + #[DataProvider('flushResults')] + public function testClearWithNoStoreArgument(bool $successful, int $exitCode): void { $this->files->shouldReceive('deleteDirectory')->once(); $this->cacheManager->shouldReceive('store')->once()->with(null)->andReturn($this->cacheRepository); - $this->cacheRepository->shouldReceive('flush')->once(); + $this->cacheRepository->shouldReceive('flush')->once()->andReturn($successful); - $this->runCommand($this->command); + $this->assertSame($exitCode, $this->runCommand($this->command)); + } + + /** + * Provide successful and failed cache flush results. + */ + public static function flushResults(): array + { + return [ + [true, SymfonyCommand::SUCCESS], + [false, SymfonyCommand::FAILURE], + ]; + } + + #[DataProvider('prohibitedOptions')] + public function testProhibitedCommandDoesNotClearCacheOrLocks(array $options): void + { + ClearCommand::prohibit(); + $this->cacheManager->shouldNotReceive('store'); + $this->files->shouldNotReceive('deleteDirectory'); + + $this->assertSame(SymfonyCommand::FAILURE, $this->runCommand($this->command, $options)); + } + + /** + * Provide the cache clearing modes. + */ + public static function prohibitedOptions(): array + { + return [[[]], [['--locks' => true]]]; } public function testClearWithStoreArgument() @@ -150,16 +182,11 @@ public function testClearLocksWillFailWhenFlushLocksFails() $this->assertSame(1, $this->runCommand($this->command, ['--locks' => true])); } - protected function runCommand($command, $input = []) + /** + * Run the cache clear command with the given input. + */ + protected function runCommand(SymfonyCommand $command, array $input = []): int { return $command->run(new ArrayInput($input), new NullOutput); } } - -class ClearCommandTestStub extends ClearCommand -{ - public function call(\Symfony\Component\Console\Command\Command|string $command, array $arguments = []): int - { - return 0; - } -} diff --git a/tests/Integration/Encryption/KeyGenerateCommandTest.php b/tests/Integration/Encryption/KeyGenerateCommandTest.php index 148925ca73..17d75e0532 100644 --- a/tests/Integration/Encryption/KeyGenerateCommandTest.php +++ b/tests/Integration/Encryption/KeyGenerateCommandTest.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Encryption\Commands\KeyGenerateCommand; use Hypervel\Filesystem\Filesystem; +use Hypervel\Prompts\Prompt; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use Override; @@ -115,6 +116,25 @@ public function testForceOptionBypassesConfirmationInProduction(): void $this->assertStringNotContainsString(base64_encode(str_repeat('a', 16)), $envContents); } + public function testDecliningConfirmationDoesNotReplaceTheKey(): void + { + $this->app->instance('env', 'production'); + // Production disables the automatic test fallback for console prompts. + Prompt::fallbackWhen(true); + + $key = 'base64:' . base64_encode(str_repeat('a', 16)); + config(['app.key' => $key]); + $path = $this->envDir . '/.env'; + file_put_contents($path, 'APP_KEY=' . $key); + + $this->artisan('key:generate') + ->expectsConfirmation('Are you sure you want to run this command?', 'no') + ->assertFailed(); + + $this->assertSame('APP_KEY=' . $key, file_get_contents($path)); + $this->assertSame($key, config('app.key')); + } + public function testErrorWhenEnvFileHasNoAppKeyLine(): void { $this->app->make('config')->set('app.key', ''); @@ -124,7 +144,7 @@ public function testErrorWhenEnvFileHasNoAppKeyLine(): void $this->artisan('key:generate') ->expectsOutputToContain('No APP_KEY variable was found in the .env file.') - ->assertSuccessful(); + ->assertFailed(); } public function testGeneratedKeyHasCorrectLengthForCipher(): void @@ -157,7 +177,7 @@ public function testProhibitedCommandDoesNotGenerateOrPublishAKey(): void $this->artisan('key:generate') ->expectsOutputToContain('This command is prohibited from running in this environment.') - ->assertSuccessful(); + ->assertFailed(); $this->assertSame('APP_KEY=', file_get_contents($path)); $this->assertSame('', $config->get('app.key')); @@ -207,7 +227,7 @@ public function testNonMatchingKeyLinesAreNotReplaced(string $line): void file_put_contents($path, $line); $this->artisan('key:generate', ['--force' => true]) - ->assertSuccessful(); + ->assertFailed(); $this->assertSame($line, file_get_contents($path)); $this->assertSame('base64:current', $config->get('app.key')); diff --git a/tests/Queue/QueueClearCommandTest.php b/tests/Queue/QueueClearCommandTest.php new file mode 100644 index 0000000000..10a3cfbf9d --- /dev/null +++ b/tests/Queue/QueueClearCommandTest.php @@ -0,0 +1,131 @@ +expects('clear')->with('default')->andReturn(2); + + $output = $this->runClearCommand($queue); + + $this->assertStringContainsString('Cleared 2 jobs from the [default] queue', $output); + } + + public function testClearingMultipleQueues(): void + { + $queue = m::mock(Queue::class, ClearableQueue::class); + $queue->expects('clear')->with('high')->andReturn(3); + $queue->expects('clear')->with('low')->andReturn(0); + $queue->expects('clear')->with('emails')->andReturn(1); + + $output = $this->runClearCommand($queue, ['--queue' => 'high,low,emails']); + + $this->assertStringContainsString('Cleared 4 jobs from the [high, low, emails] queues', $output); + } + + public function testClearingMultipleQueuesWithWhitespace(): void + { + $queue = m::mock(Queue::class, ClearableQueue::class); + $queue->expects('clear')->with('high')->andReturn(3); + $queue->expects('clear')->with('low')->andReturn(0); + + $output = $this->runClearCommand($queue, ['--queue' => 'high, low']); + + $this->assertStringContainsString('Cleared 3 jobs from the [high, low] queues', $output); + } + + public function testClearingMultipleQueuesWithEmptyValues(): void + { + $queue = m::mock(Queue::class, ClearableQueue::class); + $queue->expects('clear')->with('high')->andReturn(3); + $queue->expects('clear')->with('low')->andReturn(0); + + $output = $this->runClearCommand($queue, ['--queue' => 'high,,low']); + + $this->assertStringContainsString('Cleared 3 jobs from the [high, low] queues', $output); + } + + public function testClearingMultipleQueuesWithDuplicates(): void + { + $queue = m::mock(Queue::class, ClearableQueue::class); + $queue->expects('clear')->with('high')->andReturn(3); + $queue->expects('clear')->with('low')->andReturn(0); + + $output = $this->runClearCommand($queue, ['--queue' => 'high,low,high']); + + $this->assertStringContainsString('Cleared 3 jobs from the [high, low] queues', $output); + } + + public function testClearingDistinctNumericQueueNames(): void + { + $queue = m::mock(Queue::class, ClearableQueue::class); + $queue->expects('clear')->with('0')->andReturn(1); + $queue->expects('clear')->with('01')->andReturn(2); + $queue->expects('clear')->with('1')->andReturn(3); + + $output = $this->runClearCommand($queue, ['--queue' => '0,01,1,0,01,1']); + + $this->assertStringContainsString('Cleared 6 jobs from the [0, 01, 1] queues', $output); + } + + public function testProhibitedCommandCannotBeForced(): void + { + ClearCommand::prohibit(); + + $container = new Application; + $queueManager = m::mock(QueueManager::class); + $queueManager->shouldNotReceive('connection'); + $container->instance('queue', $queueManager); + + $command = new ClearCommand; + $command->setHypervel($container); + $output = new BufferedOutput; + + $this->assertSame(ClearCommand::FAILURE, $command->run(new ArrayInput(['--force' => true]), $output)); + $this->assertStringContainsString('This command is prohibited', $output->fetch()); + } + + /** + * Run the queue clear command and return its output. + */ + protected function runClearCommand(Queue&ClearableQueue $queue, array $arguments = []): string + { + $container = new Application; + $container->instance('env', 'testing'); + + $config = m::mock(Repository::class); + $config->expects('string')->with('queue.default')->andReturn('redis'); + $config->shouldReceive('string')->with('queue.connections.redis.queue', 'default')->andReturn('default'); + + $container->instance('config', $config); + + $queueManager = m::mock(QueueManager::class); + $queueManager->expects('connection')->with('redis')->andReturn($queue); + + $container->instance('queue', $queueManager); + + $command = new ClearCommand; + $command->setHypervel($container); + + $output = new BufferedOutput; + $this->assertSame(ClearCommand::SUCCESS, $command->run(new ArrayInput($arguments), $output)); + + return $output->fetch(); + } +} diff --git a/tests/Queue/QueueFlushFailedCommandTest.php b/tests/Queue/QueueFlushFailedCommandTest.php new file mode 100644 index 0000000000..4e1c088281 --- /dev/null +++ b/tests/Queue/QueueFlushFailedCommandTest.php @@ -0,0 +1,59 @@ +shouldNotReceive('flush'); + $app = new Application; + $app->instance(FailedJobProviderInterface::class, $provider); + $command = new FlushFailedCommand; + $command->setHypervel($app); + $output = new BufferedOutput; + + $this->assertSame(FlushFailedCommand::FAILURE, $command->run(new ArrayInput([]), $output)); + $this->assertStringContainsString('This command is prohibited', $output->fetch()); + } + + #[DataProvider('retentionOptions')] + public function testFlushesFailedJobs(array $options, ?int $hours, string $message): void + { + $provider = m::mock(FailedJobProviderInterface::class); + $provider->expects('flush')->with($hours); + $app = new Application; + $app->instance(FailedJobProviderInterface::class, $provider); + $command = new FlushFailedCommand; + $command->setHypervel($app); + $output = new BufferedOutput; + + $this->assertSame(FlushFailedCommand::SUCCESS, $command->run(new ArrayInput($options), $output)); + $this->assertStringContainsString($message, $output->fetch()); + } + + /** + * Provide failed job retention options and their completion messages. + */ + public static function retentionOptions(): array + { + return [ + [[], null, 'All failed jobs deleted successfully.'], + [['--hours' => '48'], 48, 'All jobs that failed more than 48 hours ago have been deleted successfully.'], + ]; + } +} From 6da8f260fbcaff6899171035c4e15bf37284f5cd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:18:29 +0000 Subject: [PATCH 16/23] Document conditional schema column and index changes Complete the public documentation for the already-present index callbacks: https://github.com/laravel/framework/pull/58005 Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Explain the four column/index presence and absence callbacks, show their Blueprint usage, and describe index-name or column-list selection with the optional fourth index-type argument. Laravel docs at 2914ba0b06c6be40c2f1f992555853f6266707d6 have no matching coverage. Verified the examples against the schema builder and existing docs style. Independent review and diff checks pass; no source or tests changed. --- src/docs/migrations.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 9bcd9dbba7..6d3ff8a855 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -374,6 +374,20 @@ if (Schema::hasForeignKey('posts', ['user_id'])) { The `hasForeignKey` method accepts either the foreign key name or its column list. +To modify a table only when a column or index exists, use `whenTableHasColumn` or `whenTableHasIndex`. Their `whenTableDoesntHaveColumn` and `whenTableDoesntHaveIndex` counterparts run the callback only when the column or index is absent: + +```php +Schema::whenTableDoesntHaveColumn('users', 'email', function (Blueprint $table) { + $table->string('email'); +}); + +Schema::whenTableDoesntHaveIndex('users', ['email'], function (Blueprint $table) { + $table->unique('email'); +}, 'unique'); +``` + +The index methods accept an index name or an array of column names as their second argument. You may pass an index type, such as `unique`, as the fourth argument. + #### Database Connection and Table Options From 82fd2e9c01ae598e5496e7eef8e8d4f7719ac87f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:59:03 +0000 Subject: [PATCH 17/23] Preserve expression column names for Eloquent plucks Eloquent expression plucks discarded the database-returned field name and looked up casts and accessors using the raw SQL expression. Aliased values therefore bypassed model conversion even when value() handled them correctly. Extract Query Builder's existing operation into pluckWithColumn(), returning the values and resolved field name together. Keep one query, existing fetch and selected-column restoration, both callback layers, and conditional model hydration. No SQL parser or shared metadata state is needed. Ordinary string plucks retain dispatch through Query Builder::pluck(). The approved extension-point difference affects custom query builders overriding pluck() for Eloquent expression calls; document the pluckWithColumn() override in the database README and explain the owning boundary in source. Extend expression tests for casts, quoted aliases, keys, empty results, a real accessor, and both callback layers. The complete class passes on SQLite, MySQL, MariaDB and PostgreSQL. Database suites, static analysis and formatting also pass. Addresses the expression-pluck finding in backup PR #38. --- src/database/README.md | 1 + src/database/src/Eloquent/Builder.php | 16 +++++++---- src/database/src/Query/Builder.php | 16 +++++++++-- .../Database/EloquentWhereTest.php | 28 +++++++++++++++++++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/database/README.md b/src/database/README.md index aaac3a535f..925fe3602f 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -17,5 +17,6 @@ Documentation: https://hypervel.org/docs/database - `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files. - `Blueprint::dropForeign()` widens Laravel's method signature with an optional constraint name when columns are supplied, allowing explicitly named foreign keys to be dropped portably across SQLite and the server databases. Custom `Blueprint` subclasses that override this method must accept the optional second argument. - Eloquent models that override `CREATED_AT` or `UPDATED_AT` must declare the compatible `?string` constant type, such as `public const ?string UPDATED_AT = null;`. Laravel's constants are untyped, but omitting the type from an override in Hypervel causes a fatal error. +- Eloquent expression plucks use `Query\Builder::pluckWithColumn()` to retain the returned field name for casts and accessors. Custom query builders overriding `pluck()` must also override `pluckWithColumn()` to customize this path. String-based Eloquent plucks still call `pluck()`. Ported from: https://github.com/laravel/framework diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 6ecb1d0f7c..fed1f350fe 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -991,18 +991,22 @@ protected function enforceOrderBy(): void */ public function pluck(Expression|string $column, ?string $key = null): BaseCollection { - $results = $this->toBase()->pluck($column, $key); - - $column = $column instanceof Expression ? (string) $column->getValue($this->getGrammar()) : $column; + if ($column instanceof Expression) { + // Casts and accessors use the returned field name, which cannot be + // recovered from the values alone or reliably inferred from raw SQL. + [$results, $column] = $this->toBase()->pluckWithColumn($column, $key); + } else { + $results = $this->toBase()->pluck($column, $key); - $column = Str::after($column, "{$this->model->getTable()}."); + $column = Str::after($column, "{$this->model->getTable()}."); + } // If the model has a mutator for the requested column, we will spin through // the results and mutate the values so that the mutated version of these // columns are returned as you would expect from these Eloquent models. - if (! $this->model->hasAnyGetMutator($column) + if (is_null($column) || (! $this->model->hasAnyGetMutator($column) && ! $this->model->hasCast($column) - && ! in_array($column, $this->model->getDates())) { + && ! in_array($column, $this->model->getDates()))) { return $this->applyAfterQueryCallbacks($results); } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index b21f78429a..d7dd751f13 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -3283,6 +3283,16 @@ protected function enforceOrderBy(): void * @return Collection */ public function pluck(ExpressionContract|string $column, ?string $key = null): Collection + { + return $this->pluckWithColumn($column, $key)[0]; + } + + /** + * Get column values and the returned field name for Eloquent attribute conversion. + * + * @return array{Collection, null|string} + */ + public function pluckWithColumn(ExpressionContract|string $column, ?string $key = null): array { return $this->withoutFetchUsing(function () use ($column, $key) { // First, we will need to select the results of the query accounting for the @@ -3299,7 +3309,7 @@ function () { ); if (empty($queryResult)) { - return new Collection; + return [new Collection, null]; } // If the columns are qualified with a table or have an alias, we cannot use @@ -3313,11 +3323,11 @@ function () { $key = $this->stripTableForPluck($key); - return $this->applyAfterQueryCallbacks( + return [$this->applyAfterQueryCallbacks( is_array($queryResult[0]) ? $this->pluckFromArrayColumn($queryResult, $column, $key) : $this->pluckFromObjectColumn($queryResult, $column, $key) - ); + ), $column]; }); } diff --git a/tests/Integration/Database/EloquentWhereTest.php b/tests/Integration/Database/EloquentWhereTest.php index b485c1f799..3df3765a43 100644 --- a/tests/Integration/Database/EloquentWhereTest.php +++ b/tests/Integration/Database/EloquentWhereTest.php @@ -10,6 +10,7 @@ use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Support\Collection; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -322,6 +323,12 @@ public function testExpressionValuesPreserveSqlAndModelAttributeAccess(): void } $this->assertSame(2.0, UserWhereTest::query()->withCasts(['total' => 'float'])->value(new Expression('id + 1 as total'))); + $this->assertSame([2.0], UserWhereTest::query()->withCasts(['total' => 'float'])->pluck(new Expression('id + 1 as total'))->all()); + $this->assertSame(['Taylor' => 2.0], UserWhereTest::query()->withCasts(['total' => 'float'])->pluck( + new Expression('id + 1 AS ' . DB::connection()->getQueryGrammar()->wrap('total')), + 'name' + )->all()); + $this->assertSame([], UserWhereTest::query()->where('id', 0)->pluck(new Expression('id + 1 as total'))->all()); $this->assertSame('Taylor', UserWhereTest::query()->select('name')->value(new Expression(1))); $model = new class extends UserWhereTest { @@ -334,6 +341,27 @@ public function getAttribute(string $key): mixed $this->assertSame('Total: 2', $model->newQuery()->value(new Expression('id + 1 as total'))); } + public function testExpressionPluckAppliesAccessorsAndBothQueryCallbackLayers(): void + { + UserWhereTest::create(['name' => 'Taylor', 'email' => 'taylor@example.com', 'address' => 'Main Street']); + + $model = new class extends UserWhereTest { + /** + * Format the computed total. + */ + public function getTotalAttribute(int $value): string + { + return 'Total: ' . $value; + } + }; + + $query = $model->newQuery(); + $query->getQuery()->afterQuery(fn (Collection $values): Collection => $values->map(fn (int $value): int => $value + 1)); + $query->afterQuery(fn (Collection $values): Collection => $values->map(fn (string $value): string => $value . '!')); + + $this->assertSame(['Taylor' => 'Total: 3!'], $query->pluck(new Expression('id + 1 as total'), 'name')->all()); + } + public function testChunkMap() { UserWhereTest::create([ From f8a2c8aa5f3f3fbd39e99a2611df7ad0d5b9bfea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:59:23 +0000 Subject: [PATCH 18/23] Keep HTTP retry policies tied to the current outgoing request A reused PendingRequest retained the previous managed request after switching to a custom client. Async retry policies also received the original method rather than the middleware-rewritten method. beforeSending replacements were not reflected in the captured request used by retry and response callbacks. Reset capture in sendRequest(), once per attempt. Resetting only in send() would leave stale capture when later request middleware throws or a retry policy swaps the client. Custom clients now leave capture unavailable instead of producing callbacks or response events associated with an earlier request. After beforeSending callbacks, update capture only when the final PSR request identity differs. Preserve RequestSending timing, final structured data and attributes, and the existing wrapper on the unchanged path. Use captured methods in async retry policies without changing the method used to dispatch subsequent attempts. Correct afterResponse's nullable request annotation. Keep the original async GET test alongside the middleware rewrite case. Cover managed-to-custom reuse and beforeSending replacement through synchronous and asynchronous retries and response callbacks. HTTP tests, static analysis and formatting pass. Addresses the HTTP findings in backup PR #38. --- src/http/src/Client/PendingRequest.php | 15 +++++- tests/Http/HttpClientTest.php | 71 +++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 83478bdd5e..6ee896ad0d 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -690,7 +690,7 @@ public function beforeSending(callable $callback): static /** * Add a new callback to execute after the response is built. * - * @param callable(Response, Request): (null|Response) $callback + * @param callable(Response, null|Request): (null|Response) $callback */ public function afterResponse(callable $callback): static { @@ -1135,7 +1135,7 @@ protected function handlePromiseResponse( $this->retryWhenCallback, $response instanceof Response ? $response->toException() : $response, $this, - $method + $this->request?->toPsrRequest()->getMethod() ) : true; } catch (CanceledException $exception) { throw $exception; @@ -1197,6 +1197,9 @@ protected function retryDelayInMilliseconds(int $attempt, mixed $exception): int */ protected function sendRequest(string $method, string $url, array $options = []): PromiseInterface|ResponseInterface { + // Custom clients bypass the capture middleware, including when swapped between attempts. + $this->request = null; + $clientMethod = $this->async ? 'requestAsync' : 'request'; $onStats = function (TransferStats $transferStats) { @@ -1799,6 +1802,14 @@ public function runBeforeSendingCallbacks(RequestInterface $request, array $opti $data = $request->getBody() === $preparedBody ? $originalData : []; }); + + // RequestSending observes the initial request; response callbacks and + // retry policies need any replacement returned by later callbacks. + if ($this->request?->toPsrRequest() !== $request) { + $this->request = (new Request($request)) + ->withData($data) + ->setRequestAttributes($this->attributes); + } }); } diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index ae929239a0..b15c0cae40 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -3569,7 +3569,8 @@ public function testAsyncRequestRetriesWithIntegerTries(): void $this->factory->assertSentCount(2); } - public function testAsyncRetryCallbackReceivesHttpMethod(): void + #[DataProvider('requestRewritingModes')] + public function testAsyncRetryCallbackReceivesHttpMethod(bool $rewriteMethod): void { $method = null; @@ -3579,8 +3580,13 @@ public function testAsyncRetryCallbackReceivesHttpMethod(): void ->push(['ok'], 200), ]); - $response = $this->factory - ->async() + $pendingRequest = $this->factory->async(); + + if ($rewriteMethod) { + $pendingRequest->withRequestMiddleware(static fn (RequestInterface $request): RequestInterface => $request->withMethod('PATCH')); + } + + $response = $pendingRequest ->retry(2, 0, function (Throwable $exception, PendingRequest $request, string $requestMethod) use (&$method): bool { $method = $requestMethod; @@ -3589,10 +3595,18 @@ public function testAsyncRetryCallbackReceivesHttpMethod(): void ->get('http://foo.com/get') ->wait(); - $this->assertSame('GET', $method); + $this->assertSame($rewriteMethod ? 'PATCH' : 'GET', $method); $this->assertTrue($response->successful()); } + /** + * Provide original and middleware-rewritten request methods. + */ + public static function requestRewritingModes(): array + { + return ['original' => [false], 'middleware' => [true]]; + } + public function testRetryCallbackReceivesHttpMethod(): void { $method = null; @@ -3620,7 +3634,11 @@ public function testRetryCallbackReceivesNullHttpMethodWithCustomClient(): void { $callbackCalled = false; - $response = $this->factory + $this->factory->fake(); + $pendingRequest = $this->factory->withHeaders([]); + $pendingRequest->get('http://foo.com/get'); + + $response = $pendingRequest ->setClient(new GuzzleClient([ 'handler' => static fn (): PromiseInterface => Factory::response('Failed', 500), ])) @@ -3631,12 +3649,53 @@ public function testRetryCallbackReceivesNullHttpMethodWithCustomClient(): void return false; }, throw: false) - ->get('http://foo.com/get'); + ->post('http://foo.com/post'); $this->assertTrue($callbackCalled); $this->assertSame(500, $response->status()); } + #[DataProvider('requestExecutionModes')] + public function testBeforeSendingReplacementIsUsedByRetryAndResponseCallbacks(bool $async): void + { + $method = null; + $responseMethods = []; + + $this->factory->fake([ + '*' => $this->factory->sequence()->pushStatus(500)->pushStatus(200), + ]); + + $response = $this->factory->async($async) + ->beforeSending(static fn (Request $request): RequestInterface => $request->toPsrRequest()->withMethod('PATCH')) + ->afterResponse(function (Response $response, Request $request) use (&$responseMethods): void { + $responseMethods[] = $request->method(); + }) + ->retry(2, 0, function (Throwable $exception, PendingRequest $request, ?string $requestMethod) use (&$method): bool { + $method = $requestMethod; + + return true; + }, false) + ->get('http://foo.com/get'); + + if ($async) { + $response = $response->wait(); + } + + $this->assertTrue($response->successful()); + $this->assertSame('PATCH', $method); + $this->assertSame(['PATCH', 'PATCH'], $responseMethods); + $this->factory->assertSentCount(2); + $this->factory->assertSent(fn (Request $request): bool => $request->method() === 'PATCH'); + } + + /** + * Provide synchronous and asynchronous request execution. + */ + public static function requestExecutionModes(): array + { + return ['sync' => [false], 'async' => [true]]; + } + public function testClientCanBeSet(): void { $client = $this->factory->buildClient(); From f7d264a9cc03e2c6475143b8950a4ebedbf568d4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:59:39 +0000 Subject: [PATCH 19/23] Correct database portability and bulk job count assertions The new wildcard morph-count tests passed on SQLite but used integer subtraction that underflowed for unsigned IDs on MySQL and MariaDB. They also assumed PostgreSQL would discover morph types in insertion order. Use decimal subtraction for the row-dependent count and compare bindings without assuming discovery order. Retain every expected result ID, operator case and binding value. Apply the same expression spelling to the null-only case, which was already passing. No production query changes or SQL-mode workarounds are required. Require exactly three jobs in the existing BusBatch bulk matcher before checking job identity and closure wrapping. Batch counters are maintained separately and do not establish what bulk() received. The complete morph test class passes on SQLite, MySQL, MariaDB and PostgreSQL. BusBatchTest and the affected Database/Bus suites pass, along with formatting. Addresses backup PR #38's database CI failures and batch-test review finding. --- tests/Bus/BusBatchTest.php | 3 ++- tests/Integration/Database/EloquentWhereHasMorphTest.php | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php index e30ba94423..138422cfbb 100644 --- a/tests/Bus/BusBatchTest.php +++ b/tests/Bus/BusBatchTest.php @@ -132,7 +132,8 @@ public function testJobsCanBeAddedToTheBatch(): void $connection->shouldReceive('bulk')->once()->with(m::on(function (array $args) use ($job, $secondJob, $thirdJob): bool { return - $args[0] === $job + count($args) === 3 + && $args[0] === $job && $args[1] === $secondJob && $args[2] instanceof CallQueuedClosure && $args[2]->closure->getClosure() === $thirdJob diff --git a/tests/Integration/Database/EloquentWhereHasMorphTest.php b/tests/Integration/Database/EloquentWhereHasMorphTest.php index 78c1a3170d..7c1c5f5d6e 100644 --- a/tests/Integration/Database/EloquentWhereHasMorphTest.php +++ b/tests/Integration/Database/EloquentWhereHasMorphTest.php @@ -108,12 +108,13 @@ public function testWhereHasMorphWithWildcardCountComparisons(string $operator, ->orderBy('id')->pluck('id')->all()); $count = m::mock(ExpressionContract::class); - $count->shouldReceive('getValue')->andReturn('comments.id - 7'); + // Decimal subtraction avoids unsigned integer underflow on MySQL and MariaDB. + $count->shouldReceive('getValue')->andReturn('comments.id - 7.0'); $query = Comment::whereHasMorph('commentable', '*', null, $operator, $count)->orderBy('id'); $this->assertSame($columnIds, $query->pluck('id')->all()); - $this->assertSame([Post::class, Video::class], $query->getBindings()); + $this->assertEqualsCanonicalizing([Post::class, Video::class], $query->getBindings()); } /** @@ -137,7 +138,7 @@ public function testWhereHasMorphWithExpressionCountAndOnlyNullMorphTypes(): voi { Comment::whereNotNull('commentable_type')->forceDelete(); - $this->assertSame([7], Comment::whereHasMorph('commentable', '*', null, '=', new Expression('comments.id - 7')) + $this->assertSame([7], Comment::whereHasMorph('commentable', '*', null, '=', new Expression('comments.id - 7.0')) ->orderBy('id')->pluck('id')->all()); } From 9a625fb569fbb8c297c2c3bbf8b628a2f80b1eeb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:00:09 +0000 Subject: [PATCH 20/23] Improve default mail footer and success button contrast The small footer text had insufficient contrast against the default page background. The green success button also rendered white text on a background that fell below the normal-text contrast requirement. Use zinc #71717a for footer text and links, and green #15803d for success buttons. Update all four button borders with the background because the borders provide its padding. The resulting contrast is at least 4.63:1 for the footer and 5.02:1 for the button. The affected mail suite passes. Addresses the footer review finding in backup PR #38 and the same contrast defect in the adjacent success-button rules. --- src/mail/resources/views/html/themes/default.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mail/resources/views/html/themes/default.css b/src/mail/resources/views/html/themes/default.css index 9e5da1919c..467e6bcc50 100644 --- a/src/mail/resources/views/html/themes/default.css +++ b/src/mail/resources/views/html/themes/default.css @@ -176,13 +176,13 @@ img { } .footer p { - color: #a1a1aa; + color: #71717a; font-size: 12px; text-align: center; } .footer a { - color: #a1a1aa; + color: #71717a; text-decoration: underline; } @@ -248,11 +248,11 @@ img { .button-green, .button-success { - background-color: #16a34a; - border-bottom: 8px solid #16a34a; - border-left: 18px solid #16a34a; - border-right: 18px solid #16a34a; - border-top: 8px solid #16a34a; + background-color: #15803d; + border-bottom: 8px solid #15803d; + border-left: 18px solid #15803d; + border-right: 18px solid #15803d; + border-top: 8px solid #15803d; } .button-red, From d41c2f695ef6170650febddc3b16c4b49fac85a2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:41:58 +0000 Subject: [PATCH 21/23] Synchronize the serializable closure dependency floor across packages Complete the 2.0.11 minimum-version update across all split packages that directly require laravel/serializable-closure. The root and queue manifests already required the fixed release, but nine split manifests still allowed 2.0.10 and failed the existing package consistency checks. This completes the dependency adaptation for https://github.com/laravel/framework/pull/57881. Keep the upstream wrapper-preservation fix without adding compatibility branches. Root dependencies and lockfiles are unchanged. Verified the existing manifest and package metadata tests, generated facade checks, source and type analysis, formatting, and the full parallel framework suite. --- src/bus/composer.json | 2 +- src/cache/composer.json | 2 +- src/concurrency/composer.json | 2 +- src/data/composer.json | 2 +- src/events/composer.json | 2 +- src/foundation/composer.json | 2 +- src/routing/composer.json | 2 +- src/support/composer.json | 2 +- src/wayfinder/composer.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/bus/composer.json b/src/bus/composer.json index b2185bb42f..56213bbf4c 100644 --- a/src/bus/composer.json +++ b/src/bus/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": "^8.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "nesbot/carbon": "^3.13.1", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", diff --git a/src/cache/composer.json b/src/cache/composer.json index 129a104bd3..df114f37f1 100644 --- a/src/cache/composer.json +++ b/src/cache/composer.json @@ -45,7 +45,7 @@ "hypervel/macroable": "^0.4", "hypervel/redis": "^0.4", "hypervel/support": "^0.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "psr/simple-cache": "^3.0", "symfony/console": "^8.1" }, diff --git a/src/concurrency/composer.json b/src/concurrency/composer.json index 7aad3c7083..13f46c652a 100644 --- a/src/concurrency/composer.json +++ b/src/concurrency/composer.json @@ -31,7 +31,7 @@ }, "require": { "php": "^8.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", diff --git a/src/data/composer.json b/src/data/composer.json index 734a83a1c0..c7daa37bd9 100644 --- a/src/data/composer.json +++ b/src/data/composer.json @@ -44,7 +44,7 @@ "hypervel/reflection": "^0.4", "hypervel/support": "^0.4", "hypervel/validation": "^0.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "nesbot/carbon": "^3.13.1", "phpstan/phpdoc-parser": "^2.3", "symfony/console": "^8.1", diff --git a/src/events/composer.json b/src/events/composer.json index 813007b70c..91788df0c8 100644 --- a/src/events/composer.json +++ b/src/events/composer.json @@ -33,7 +33,7 @@ }, "require": { "php": "^8.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", "hypervel/container": "^0.4", diff --git a/src/foundation/composer.json b/src/foundation/composer.json index e0c0be5753..c0989bf40f 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -29,7 +29,7 @@ "ext-posix": "*", "brick/math": "^0.17", "guzzlehttp/guzzle": "^7.15.1", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "league/flysystem": "^3.25.1", "league/uri": "^7.5.1", "monolog/monolog": "^3.1", diff --git a/src/routing/composer.json b/src/routing/composer.json index 621e8a3a1a..530dcd7b94 100644 --- a/src/routing/composer.json +++ b/src/routing/composer.json @@ -49,7 +49,7 @@ "hypervel/session": "^0.4", "hypervel/support": "^0.4", "hypervel/view": "^0.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "psr/http-message": "^2.0", "symfony/console": "^8.1", "symfony/http-foundation": "^8.1", diff --git a/src/support/composer.json b/src/support/composer.json index f557da3d79..0d0e5e734c 100644 --- a/src/support/composer.json +++ b/src/support/composer.json @@ -27,7 +27,7 @@ "ext-filter": "*", "doctrine/inflector": "^2.0.5", "guzzlehttp/promises": "^2.5.2", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "league/commonmark": "^2.10", "league/uri": "^7.5.1", "nesbot/carbon": "^3.13.1", diff --git a/src/wayfinder/composer.json b/src/wayfinder/composer.json index 1022bc9728..d299d40ed8 100644 --- a/src/wayfinder/composer.json +++ b/src/wayfinder/composer.json @@ -44,7 +44,7 @@ "hypervel/routing": "^0.4", "hypervel/support": "^0.4", "hypervel/view": "^0.4", - "laravel/serializable-closure": "^2.0.10", + "laravel/serializable-closure": "^2.0.11", "phpstan/phpdoc-parser": "^2.3", "symfony/console": "^8.1" }, From 9d7c100618f0b4f72a2ad48abeca19b25d7cccdb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:41:58 +0000 Subject: [PATCH 22/23] Regenerate Event and Http facade annotations after upstream type updates Refresh the generated facade contracts to match the updated event listener, HTTP fake response body and retry callback types. The facade consistency test caught annotations that had not been regenerated with their source changes. Generated with the repository facade documenter. This completes the annotation propagation for Laravel PRs https://github.com/laravel/framework/pull/57986, https://github.com/laravel/framework/pull/61047 and https://github.com/laravel/framework/pull/61106 without changing runtime behavior. Verified facade generation consistency, source and type analysis, formatting, and the full parallel framework suite. --- src/support/src/Facades/Event.php | 2 +- src/support/src/Facades/Http.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php index ff66b9dcdb..7bb4b5b56c 100644 --- a/src/support/src/Facades/Event.php +++ b/src/support/src/Facades/Event.php @@ -24,7 +24,7 @@ * @method static bool hasWildcardListeners(string $eventName) * @method static void listen(\Closure|\Hypervel\Events\QueuedClosure|array|string $events, object|array|string|null $listener = null) * @method static void macro(string $name, callable|object $macro) - * @method static \Closure makeListener(object|array|string $listener, bool $wildcard = false) + * @method static \Closure makeListener(array|object|string $listener, bool $wildcard = false) * @method static void mixin(object $mixin, bool $replace = true) * @method static void observe(array|string $events, object|array|string $observer) * @method static void push(string $event, mixed $payload = []) diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php index bafd73648b..c6875b5d61 100644 --- a/src/support/src/Facades/Http.php +++ b/src/support/src/Facades/Http.php @@ -20,7 +20,7 @@ * @method static \GuzzleHttp\ClientInterface createClient(\GuzzleHttp\HandlerStack $handlerStack, \GuzzleHttp\Cookie\CookieJar $cookies) * @method static \Hypervel\Http\Client\PendingRequest createPendingRequest() * @method static \Closure failedConnection(string|null $message = null) - * @method static \Hypervel\Http\Client\RequestException failedRequest(mixed $body = null, int $status = 200, array $headers = []) + * @method static \Hypervel\Http\Client\RequestException failedRequest(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) * @method static void flushMacros() * @method static void flushState() * @method static \Hypervel\Http\Client\Factory forgetConnectionHandlers() @@ -40,12 +40,12 @@ * @method static mixed macroCall(string $method, array $parameters) * @method static void mixin(object $mixin, bool $replace = true) * @method static bool preventingStrayRequests() - * @method static \GuzzleHttp\Psr7\Response psr7Response(mixed $body = null, int $status = 200, array $headers = []) + * @method static \GuzzleHttp\Psr7\Response psr7Response(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) * @method static \Hypervel\Http\Client\Factory record() * @method static \Hypervel\Support\Collection recorded(callable|null $callback = null) * @method static void recordRequestResponsePair(\Hypervel\Http\Client\Request $request, \Hypervel\Http\Client\Response|null $response) * @method static \Hypervel\Http\Client\Factory registerConnection(string $name, array $config = []) - * @method static \GuzzleHttp\Promise\PromiseInterface response(mixed $body = null, int $status = 200, array $headers = []) + * @method static \GuzzleHttp\Promise\PromiseInterface response(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) * @method static \Hypervel\Http\Client\ResponseSequence sequence(array $responses = []) * @method static \Hypervel\Http\Client\Factory setConnectionConfig(string $name, array $config) * @method static \Hypervel\Http\Client\PendingRequest accept(string $contentType) @@ -87,7 +87,7 @@ * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response put(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response query(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) * @method static \Hypervel\Http\Client\PendingRequest replaceHeaders(array $headers) - * @method static \Hypervel\Http\Client\PendingRequest retry(array|int $times, \Closure|int $sleepMilliseconds = 0, callable|null $when = null, bool $throw = true) + * @method static \Hypervel\Http\Client\PendingRequest retry(array|int $times, \Closure|int $sleepMilliseconds = 0, null|callable $when = null, bool $throw = true) * @method static \Psr\Http\Message\RequestInterface runBeforeSendingCallbacks(\Psr\Http\Message\RequestInterface $request, array $options) * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response send(string $method, string $url, array $options = []) * @method static \Hypervel\Http\Client\PendingRequest setClient(\GuzzleHttp\ClientInterface $client) From cdcab0ecfd133e0dffbf896261ec0e6a685edf10 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:41:58 +0000 Subject: [PATCH 23/23] Keep worker limit and polling tests independent of suite memory usage The worker checks its memory limit before restart polling and job/time limits. In the full suite, the default 128MB limit caused the disabled-pause test to miss its second restart-key read. The disabled-restart and max-job/time tests could also pass after an unintended memory exit. Use the existing 1024MB test allowance in all four fixtures and assert a successful exit. Preserve all job and exact cache-call assertions, remove the misplaced memory comments from the max-limit tests, and leave dedicated memory-limit tests unchanged. Completes the adapted polling coverage from https://github.com/laravel/framework/pull/57975. Verified the WorkCommand test file, all four cases with retained memory above the old limit, the full parallel framework suite, formatting and analysis. The review also verified that exceeding the new limit makes all four success assertions fail. --- tests/Integration/Queue/WorkCommandTest.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 14963d2d98..e128e366d3 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -232,7 +232,7 @@ public function testMemoryExceeded() $this->assertFalse(SecondJob::$ran); } - public function testMaxJobsExceeded() + public function testMaxJobsExceeded(): void { $this->markTestSkippedWhenUsingQueueDrivers(['redis', 'beanstalkd']); @@ -243,15 +243,15 @@ public function testMaxJobsExceeded() '--daemon' => true, '--stop-when-empty' => true, '--max-jobs' => 1, - ]); + '--memory' => 1024, + ])->assertExitCode(0); - // Memory limit isn't checked until after the first job is attempted. $this->assertSame(1, Queue::size()); $this->assertTrue(FirstJob::$ran); $this->assertFalse(SecondJob::$ran); } - public function testMaxTimeExceeded() + public function testMaxTimeExceeded(): void { $this->markTestSkippedWhenUsingQueueDrivers(['redis', 'beanstalkd']); @@ -263,9 +263,9 @@ public function testMaxTimeExceeded() '--daemon' => true, '--stop-when-empty' => true, '--max-time' => 1, - ]); + '--memory' => 1024, + ])->assertExitCode(0); - // Memory limit isn't checked until after the first job is attempted. $this->assertSame(2, Queue::size()); $this->assertTrue(ThirdJob::$ran); $this->assertFalse(FirstJob::$ran); @@ -314,7 +314,8 @@ public function testDisableLastRestartCheck(): void $this->artisan('queue:work', [ '--max-jobs' => 1, '--stop-when-empty' => true, - ]); + '--memory' => 1024, + ])->assertExitCode(0); $this->assertSame(0, Queue::size()); $this->assertTrue(FirstJob::$ran); @@ -341,7 +342,8 @@ public function testDisablePauseQueueCheck(): void $this->artisan('queue:work', [ '--max-jobs' => 1, '--stop-when-empty' => true, - ]); + '--memory' => 1024, + ])->assertExitCode(0); $this->assertSame(0, Queue::size()); $this->assertTrue(FirstJob::$ran);