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/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/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/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/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/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/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/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/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..fed1f350fe 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}; } /** @@ -983,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 ? $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); } @@ -1924,7 +1936,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/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/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 e5b800333a..5bb3ca9eda 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); - } + // Each morph type must traverse the same remaining path independently. + $closure = static fn (Builder $query): Builder => $query->has($remaining, $operator, $count, 'and', $callback); - // 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); - }; - - 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) { @@ -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|Expression $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([ @@ -437,9 +435,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 +454,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 +473,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 +493,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 +509,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 +525,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 +541,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 +794,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,14 +866,14 @@ 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))) ) ); $this->ensureNoTimeoutOnRelationshipConstraint($query); if (is_null($this->query->columns)) { - $this->query->select([$this->query->from . '.*']); + $this->query->select([$this->query->getDefaultSelectColumn()]); } if ($function === 'exists') { @@ -921,7 +919,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 +927,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 +935,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 +943,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 +962,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 +1020,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 +1061,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/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/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/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..d7dd751f13 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 @@ -3256,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 @@ -3272,21 +3309,25 @@ 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 // 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); - return $this->applyAfterQueryCallbacks( + return [$this->applyAfterQueryCallbacks( is_array($queryResult[0]) ? $this->pluckFromArrayColumn($queryResult, $column, $key) : $this->pluckFromObjectColumn($queryResult, $column, $key) - ); + ), $column]; }); } @@ -3968,7 +4009,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/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/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 16c71e1b6b..76724bb739 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 @@ -1686,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 @@ -1894,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 @@ -2181,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/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/http-client.md b/src/docs/http-client.md index 642a68940d..df115a61e2 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; } @@ -1043,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/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/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 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/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/docs/queues.md b/src/docs/queues.md index bc81f00988..6e7d991bc5 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; @@ -2680,7 +2697,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 @@ -3191,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 @@ -3292,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. @@ -3639,7 +3678,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 +3707,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 +3721,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/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/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/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/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()); 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/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/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/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/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/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/PendingRequest.php b/src/http/src/Client/PendingRequest.php index e9efc7d6a0..6ee896ad0d 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, @@ -686,6 +689,8 @@ public function beforeSending(callable $callback): static /** * Add a new callback to execute after the response is built. + * + * @param callable(Response, null|Request): (null|Response) $callback */ public function afterResponse(callable $callback): static { @@ -910,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(); } } ); @@ -962,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; @@ -1124,7 +1134,8 @@ protected function handlePromiseResponse( $shouldRetry = $this->retryWhenCallback ? call_user_func( $this->retryWhenCallback, $response instanceof Response ? $response->toException() : $response, - $this + $this, + $this->request?->toPsrRequest()->getMethod() ) : true; } catch (CanceledException $exception) { throw $exception; @@ -1186,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) { @@ -1659,6 +1673,8 @@ function ($reason) use ($request, $options) { /** * Build the stub handler. + * + * @throws StrayRequestException */ public function buildStubHandler(): Closure { @@ -1786,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/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/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/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..467e6bcc50 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: #71717a; font-size: 12px; text-align: center; } .footer a { - color: #b0adc5; + color: #71717a; 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: #15803d; + border-bottom: 8px solid #15803d; + border-left: 18px solid #15803d; + border-right: 18px solid #15803d; + border-top: 8px solid #15803d; } .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/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/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/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/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/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/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/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/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/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/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/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) 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/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/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/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/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" }, diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php index af9a501545..138422cfbb 100644 --- a/tests/Bus/BusBatchTest.php +++ b/tests/Bus/BusBatchTest.php @@ -123,18 +123,20 @@ 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 + count($args) === 3 + && $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/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/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index a08104d15b..ae715f1111 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; @@ -372,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); @@ -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/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/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); 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/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index a86790c8b2..b15c0cae40 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([ @@ -3539,6 +3569,133 @@ public function testAsyncRequestRetriesWithIntegerTries(): void $this->factory->assertSentCount(2); } + #[DataProvider('requestRewritingModes')] + public function testAsyncRetryCallbackReceivesHttpMethod(bool $rewriteMethod): void + { + $method = null; + + $this->factory->fake([ + '*' => $this->factory->sequence() + ->push(['error'], 500) + ->push(['ok'], 200), + ]); + + $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; + + return true; + }, false) + ->get('http://foo.com/get') + ->wait(); + + $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; + + $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; + + $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), + ])) + ->retry(1, when: function (Throwable $exception, PendingRequest $request, ?string $method) use (&$callbackCalled): bool { + $callbackCalled = true; + + $this->assertNull($method); + + return false; + }, throw: false) + ->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(); @@ -4206,7 +4363,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 +5207,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 +5218,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 +5360,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 +5511,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 +5626,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([ 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/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/Integration/Database/EloquentWhereHasMorphTest.php b/tests/Integration/Database/EloquentWhereHasMorphTest.php index 776b1dd351..7c1c5f5d6e 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,64 @@ 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); + // 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->assertEqualsCanonicalizing([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.0')) + ->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/tests/Integration/Database/EloquentWhereTest.php b/tests/Integration/Database/EloquentWhereTest.php index e10bc3f894..3df3765a43 100644 --- a/tests/Integration/Database/EloquentWhereTest.php +++ b/tests/Integration/Database/EloquentWhereTest.php @@ -8,7 +8,9 @@ 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\Collection; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -306,6 +308,60 @@ 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([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 { + 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 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([ 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/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/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/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/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() 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; 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/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 6e0e9931a9..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; @@ -41,7 +44,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 +66,27 @@ public function testUniqueJobsAreNotDispatched() ); } - public function testUniqueJobWithViaDispatched() + 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(); @@ -71,7 +94,7 @@ public function testUniqueJobWithViaDispatched() Bus::assertDispatched(UniqueViaJob::class); } - public function testLockIsReleasedForSuccessfulJobs() + public function testLockIsReleasedForSuccessfulJobs(): void { UniqueTestJob::$handled = false; dispatch($job = new UniqueTestJob); @@ -81,7 +104,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 +118,7 @@ public function testLockIsReleasedForFailedJobs() } } - public function testLockIsNotReleasedForJobRetries() + public function testLockIsNotReleasedForJobRetries(): void { $this->markTestSkippedWhenUsingSyncQueueDriver(); @@ -117,7 +140,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 +161,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 +177,46 @@ public function testLockCanBeReleasedBeforeProcessing() $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } - public function testLockIsReleasedOnModelNotFoundException() + 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; - /** @var \Illuminate\Foundation\Auth\User */ + /** @var User $user */ $user = UserFactory::new()->create(); $job = new UniqueTestSerializesModelsJob($user); @@ -176,7 +234,58 @@ public function testLockIsReleasedOnModelNotFoundException() } } - public function testQueueFakeReleasesUniqueJobLocksBetweenFakes() + 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(); @@ -189,7 +298,7 @@ public function testQueueFakeReleasesUniqueJobLocksBetweenFakes() Queue::assertPushed(UniqueTestJob::class); } - public function testQueueFakePreservesUniqueJobLockWithinTest() + public function testQueueFakePreservesUniqueJobLockWithinTest(): void { Queue::fake(); @@ -199,12 +308,35 @@ public function testQueueFakePreservesUniqueJobLockWithinTest() Queue::assertPushedTimes(UniqueTestJob::class, 1); } - protected function getLockKey($job) + 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. + */ + 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 +360,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) ); @@ -315,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; @@ -362,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/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index fd3ff99cb0..e128e366d3 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; @@ -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); @@ -301,23 +301,21 @@ 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); $this->artisan('queue:work', [ '--max-jobs' => 1, '--stop-when-empty' => true, - ]); + '--memory' => 1024, + ])->assertExitCode(0); $this->assertSame(0, Queue::size()); $this->assertTrue(FirstJob::$ran); @@ -325,7 +323,7 @@ public function testDisableLastRestartCheck(): void Worker::$restartable = true; } - public function testDisablePauseQueueCheck() + public function testDisablePauseQueueCheck(): void { $this->markTestSkippedWhenUsingQueueDrivers(['redis', 'beanstalkd']); @@ -333,21 +331,19 @@ 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); $this->artisan('queue:work', [ '--max-jobs' => 1, '--stop-when-empty' => true, - ]); + '--memory' => 1024, + ])->assertExitCode(0); $this->assertSame(0, Queue::size()); $this->assertTrue(FirstJob::$ran); 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; 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.'], + ]; + } +} 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 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/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/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)); 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()); 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 { 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));