diff --git a/src/ai/src/Streaming/Protocols/StreamProtocol.php b/src/ai/src/Streaming/Protocols/StreamProtocol.php index d66f45e792..764e00dc2e 100644 --- a/src/ai/src/Streaming/Protocols/StreamProtocol.php +++ b/src/ai/src/Streaming/Protocols/StreamProtocol.php @@ -7,8 +7,8 @@ use Generator; use Hypervel\Ai\Exceptions\StreamErrorException; use Hypervel\Ai\Responses\StreamableAgentResponse; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\IterableStreamedResponse; -use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -54,9 +54,11 @@ public function response(StreamableAgentResponse $response): Response foreach ($this->parts($response) as $part) { yield $this->encode($part); } - } catch (CanceledException $e) { - throw $e; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $this->exception = $e; // A stream error exception carries a provider error the stream already surfaced, so only report anything else... diff --git a/src/collections/README.md b/src/collections/README.md index 1851458d66..37f52128b5 100644 --- a/src/collections/README.md +++ b/src/collections/README.md @@ -5,8 +5,6 @@ Collections for Hypervel Documentation: https://hypervel.org/docs/collections -Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Collections - ## Differences From Laravel Laravel's deprecated `containsOneItem()` and `containsManyItems()` aliases are intentionally not ported. Use `hasSole()` and `hasMany()` instead. @@ -14,3 +12,5 @@ Laravel's deprecated `containsOneItem()` and `containsManyItems()` aliases are i `Enumerable::random()` exposes the optional `preserveKeys` argument supported by both concrete collection implementations and reports the preserved key type accurately. Laravel exposes this argument only on the concrete classes and always reports integer keys through the contract. `Arr::push()` accepts arrays only. Laravel also advertises `ArrayAccess`, but dot notation requires nested by-reference mutation that `ArrayAccess` cannot provide. + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Collections diff --git a/src/concurrency/README.md b/src/concurrency/README.md index a0069d9a3c..e575350db8 100644 --- a/src/concurrency/README.md +++ b/src/concurrency/README.md @@ -5,8 +5,8 @@ Concurrency for Hypervel Documentation: https://hypervel.org/docs/concurrency -Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Concurrency - ## Differences From Laravel Hypervel uses its Swoole coroutine driver by default; the process and sync drivers remain available explicitly. Laravel's fork driver is omitted because coroutine concurrency is Hypervel's native lightweight execution model. + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Concurrency diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index 36b3ce15b1..740ba00759 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -107,6 +107,11 @@ class Schedule */ public static bool $interruptible = true; + /** + * Indicates if every event should only run on one server for each cron expression. + */ + public static bool $alwaysOnOneServer = false; + /** * Create a new schedule instance. * @@ -394,7 +399,7 @@ public function dueEvents(Application $app): Collection */ public function dueEventsAt(Application $app, DateTimeInterface $time): Collection { - return (new Collection($this->events)) + return (new Collection($this->events())) ->filter(fn (Event $event) => $event->isDueAt($app, $time)); } @@ -405,6 +410,16 @@ public function dueEventsAt(Application $app, DateTimeInterface $time): Collecti */ public function events(): array { + if (static::$alwaysOnOneServer) { + foreach ($this->events as $event) { + if ($event instanceof CallbackEvent && $event->description === null) { + continue; + } + + $event->onOneServer(); + } + } + return $this->events; } @@ -497,6 +512,17 @@ public function hasBeenInterruptedSince(DateTimeInterface $time): bool return is_numeric($interruptedAt) && (int) $interruptedAt >= Date::instance($time)->getTimestampMs(); } + /** + * Indicate that every event on the schedule should only run on one server for each cron expression. + * + * Boot-only. The flag persists for the worker lifetime and affects every + * schedule evaluated in the process. + */ + public static function alwaysOnOneServer(bool $value = true): void + { + static::$alwaysOnOneServer = $value; + } + /** * Flush all static state. */ @@ -504,6 +530,7 @@ public static function flushState(): void { static::$pausable = true; static::$interruptible = true; + static::$alwaysOnOneServer = false; static::flushMacros(); } diff --git a/src/contracts/src/Routing/ResponseFactory.php b/src/contracts/src/Routing/ResponseFactory.php index 78253f4f74..090252b874 100644 --- a/src/contracts/src/Routing/ResponseFactory.php +++ b/src/contracts/src/Routing/ResponseFactory.php @@ -43,6 +43,11 @@ public function json(mixed $data = [], int $status = 200, array $headers = [], i */ public function jsonp(string $callback, mixed $data = [], int $status = 200, array $headers = [], int $options = 0): JsonResponse; + /** + * Create a new Markdown response instance. + */ + public function markdown(string $content = '', int $status = 200, array $headers = []): Response; + /** * Create a new event stream response. * @@ -53,7 +58,7 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven /** * Create a new streamed response instance. */ - public function stream(callable $callback, int $status = 200, array $headers = []): StreamedResponse; + public function stream(?callable $callback, int $status = 200, array $headers = []): StreamedResponse; /** * Create a new streamed JSON response instance. diff --git a/src/database/src/BinaryParameter.php b/src/database/src/BinaryParameter.php index 8f658b7d76..deb83f3ea2 100644 --- a/src/database/src/BinaryParameter.php +++ b/src/database/src/BinaryParameter.php @@ -15,6 +15,19 @@ public function __construct(public string $value) { } + /** + * Create a binary parameter from a stream and rewind it. + * + * @param resource $stream + */ + public static function fromStream(mixed $stream): static + { + $value = stream_get_contents($stream, offset: 0); + rewind($stream); + + return new static($value); + } + /** * Return the binary parameter value. */ diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 823cdb2c9f..9a052ab9da 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -7,6 +7,7 @@ use Generator; use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Model; use Hypervel\Database\MultipleRecordsFoundException; use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Expression; @@ -205,7 +206,7 @@ public function orderedChunkById(int $count, callable $callback, ?string $column return false; } - $lastId = data_get($results->last(), $alias); + $lastId = $this->getNextPageIdFromChunk($results->last(), $column, $alias); if ($lastId === null) { throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result."); @@ -283,6 +284,8 @@ public function lazy(int $chunkSize = 1000): LazyCollection return; } + unset($results); + ++$page; } }); @@ -366,17 +369,72 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null return; } - $lastId = data_get($results->last(), $alias); + $lastId = $this->getNextPageIdFromChunk($results->last(), $column, $alias); if ($lastId === null) { throw new RuntimeException("The lazyById operation was aborted because the [{$alias}] column is not present in the query result."); } + unset($results); + ++$page; } }); } + /** + * Get the ID of the last result of a chunk to continue paging from. + */ + protected function getLastIdFromChunk(mixed $result, string $alias): mixed + { + if (! $result instanceof Model) { + return data_get($result, $alias); + } + + // Callbacks and accessors can provide cursor aliases that are not selected columns. + return array_key_exists($alias, $original = $result->getRawOriginal()) + ? $original[$alias] + : data_get($result, $alias); + } + + /** + * Prepare the last chunk ID for comparison with the paging column. + */ + protected function getNextPageIdFromChunk(mixed $result, string $column, string $alias): mixed + { + $lastId = $this->getLastIdFromChunk($result, $alias); + + if (! $result instanceof Model) { + return $lastId; + } + + // Either the selected alias or the model's paging column can declare binary binding intent. + $attribute = $this->getPagingAttribute($result, $column); + $lastId = $result->prepareBinaryAttributesForDatabase([$alias => $lastId])[$alias]; + + return $result->prepareBinaryAttributesForDatabase([$attribute => $lastId])[$attribute]; + } + + /** + * Get the model attribute behind a qualified paging column. + */ + protected function getPagingAttribute(Model $model, string $column): string + { + if ($model->hasCast($column)) { + return $column; + } + + $prefixes = [$model->getTable() . '.']; + + $query = $this instanceof Builder ? $this->getQuery() : $this; + + if (($alias = $query->getFromAlias()) !== null) { + $prefixes[] = $alias . '.'; + } + + return Str::chopStart($column, $prefixes); + } + /** * Execute the query and get the first result. * @@ -454,8 +512,9 @@ protected function paginateUsingCursor( // Reset the union bindings so we can add the cursor where in the correct position... $this->setBindings([], 'union'); - $addCursorConditions = function (self $builder, $previousColumn, $originalColumn, $i) use (&$addCursorConditions, $cursor, $orders) { - $unionBuilders = $builder->getUnionBuilders(); + $addCursorConditions = function (QueryBuilder|Builder $builder, $previousColumn, $originalColumn, $i) use (&$addCursorConditions, $cursor, $orders) { + // Unions are base queries; the other builder family only appears in nested constraints without unions. + $unionBuilders = $builder instanceof self ? $builder->getUnionBuilders() : new Collection; if (! is_null($previousColumn)) { $originalColumn ??= $this->getOriginalColumnNameForCursorPagination($this, $previousColumn); @@ -463,21 +522,21 @@ protected function paginateUsingCursor( $builder->where( Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, '=', - $cursor->parameter($previousColumn) + $this->getCursorParameterBinding($cursor, $previousColumn) ); $unionBuilders->each(function ($unionBuilder) use ($previousColumn, $cursor) { $unionBuilder->where( $this->getOriginalColumnNameForCursorPagination($unionBuilder, $previousColumn), '=', - $cursor->parameter($previousColumn) + $this->getCursorParameterBinding($cursor, $previousColumn) ); $this->addBinding($unionBuilder->getRawBindings()['where'], 'union'); }); } - $builder->where(function (self $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) { + $builder->where(function (QueryBuilder|Builder $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) { ['column' => $column, 'direction' => $direction] = $orders[$i]; $originalColumn = $this->getOriginalColumnNameForCursorPagination($this, $column); @@ -485,11 +544,11 @@ protected function paginateUsingCursor( $secondBuilder->where( Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, $direction === 'asc' ? '>' : '<', - $cursor->parameter($column) + $this->getCursorParameterBinding($cursor, $column) ); if ($i < $orders->count() - 1) { - $secondBuilder->orWhere(function (self $thirdBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { + $secondBuilder->orWhere(function (QueryBuilder|Builder $thirdBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { $addCursorConditions($thirdBuilder, $column, $originalColumn, $i + 1); }); } @@ -502,11 +561,11 @@ protected function paginateUsingCursor( $unionBuilder->where( $originalColumn, $direction === 'asc' ? '>' : '<', - $cursor->parameter($column) + $this->getCursorParameterBinding($cursor, $column) ); if ($i < $orders->count() - 1) { - $unionBuilder->orWhere(function (self $fourthBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { + $unionBuilder->orWhere(function (QueryBuilder|Builder $fourthBuilder) use ($addCursorConditions, $column, $originalColumn, $i) { $addCursorConditions($fourthBuilder, $column, $originalColumn, $i + 1); }); } @@ -530,6 +589,14 @@ protected function paginateUsingCursor( ]); } + /** + * Get a cursor parameter for database binding. + */ + protected function getCursorParameterBinding(Cursor $cursor, string $parameter): mixed + { + return $cursor->parameter($parameter); + } + /** * Get the original column name of the given column, without any aliasing. * diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index 56cc917495..10635c2442 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -571,8 +571,12 @@ public function allowQueryDurationHandlersToRunAgain(): void * worker are not affected. A null value clears the override and * getDefaultConnection() falls back to config('database.default'). */ - public function setDefaultConnection(?string $name): void + public function setDefaultConnection(UnitEnum|string|null $name): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + if ($name === null) { CoroutineContext::forget(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); } else { diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 42925cdab6..52821cc823 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1186,6 +1186,17 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): ->values(); } + /** + * Get a cursor parameter for database binding. + */ + protected function getCursorParameterBinding(Cursor $cursor, string $parameter): mixed + { + // Reverse the cast that produced the readable cursor, preserving qualified pivot casts. + $key = $this->getPagingAttribute($this->model, $parameter); + + return $this->model->encodeBinaryAttributeForDatabase($key, $cursor->parameter($parameter)); + } + /** * Save a new model and return the instance. * @@ -1216,7 +1227,7 @@ public function createQuietly(array $attributes = []): Model public function forceCreate(array $attributes): Model { return $this->model::unguarded(function () use ($attributes) { - return $this->newModelInstance()->create($attributes); + return $this->create($attributes); }); } diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index fb0439bd41..562cb8a028 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -1954,7 +1954,7 @@ private function isBinaryCast(?string $cast): bool } /** - * Prepare binary-cast attributes for database binding. + * Wrap raw binary attribute bytes or streams for database binding. * * @param array $attributes * @return array @@ -1969,12 +1969,8 @@ public function prepareBinaryAttributesForDatabase(array $attributes): array } if (is_resource($value)) { - $binary = stream_get_contents($value, offset: 0); - rewind($value); - $value = $binary; - } - - if (is_string($value)) { + $attributes[$key] = BinaryParameter::fromStream($value); + } elseif (is_string($value)) { $attributes[$key] = new BinaryParameter($value); } } @@ -1982,6 +1978,26 @@ public function prepareBinaryAttributesForDatabase(array $attributes): array return $attributes; } + /** + * Encode a binary attribute's cast value, such as a UUID string, for database binding. + * + * Preserve explicit binary parameters and raw streams without encoding them again. + */ + public function encodeBinaryAttributeForDatabase(string $key, mixed $value): mixed + { + if ($value instanceof BinaryParameter || ! $this->isBinaryCast($this->getCasts()[$key] ?? null)) { + return $value; + } + + if (is_resource($value)) { + return BinaryParameter::fromStream($value); + } + + $bytes = $this->resolveCasterClass($key)->set($this, $key, $value, $this->attributes)[$key]; + + return is_string($bytes) ? new BinaryParameter($bytes) : $bytes; + } + /** * Set the array of model attributes. No checking is done. */ diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index fd142dca43..5bc99afb19 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -1058,6 +1058,15 @@ public function cursorPaginate( ): mixed { $this->query->addSelect($this->shouldSelect($columns)); + $casts = []; + + // Cursor parameters from the pivot must be encoded with the pivot's casts, not the related model's. + foreach ($this->newPivot()->getCasts() as $key => $cast) { + $casts[$this->qualifyPivotColumn($key)] = $cast; + } + + $this->query->withCasts($casts); + return tap($this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor), function ($paginator) { $this->hydratePivotRelation($paginator->items()); }); diff --git a/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php index 091dae214d..1f287f4000 100644 --- a/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php +++ b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php @@ -16,12 +16,12 @@ trait ComparesRelatedModels public function is(?Model $model): bool { $match = ! is_null($model) - && $this->compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model)) && $this->related->getTable() === $model->getTable() - && ConnectionName::withoutWriteType($this->related->getConnectionName()) === ConnectionName::withoutWriteType($model->getConnectionName()); + && ConnectionName::withoutWriteType($this->related->getConnectionName()) === ConnectionName::withoutWriteType($model->getConnectionName()) + && $this->compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model)); if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) { - return $this->query + return (clone $this->query) ->whereKey($model->getKey()) ->exists(); } diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index 10da867cdc..9da47cdef1 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -393,7 +393,9 @@ public function createQuietly(array $attributes = []): Model */ public function forceCreate(array $attributes = []): Model { - $attributes[$this->getForeignKeyName()] = $this->getParentKey(); + // Pending-attribute mutators may read the relationship key. + $attributes = [$this->getForeignKeyName() => $this->getParentKey()] + + array_merge($this->getQuery()->pendingAttributes, $attributes); return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); } diff --git a/src/database/src/Eloquent/Relations/HasOneThrough.php b/src/database/src/Eloquent/Relations/HasOneThrough.php index df6a809185..30fd44476a 100644 --- a/src/database/src/Eloquent/Relations/HasOneThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneThrough.php @@ -5,6 +5,7 @@ namespace Hypervel\Database\Eloquent\Relations; use Hypervel\Contracts\Database\Eloquent\SupportsPartialRelations; +use Hypervel\Database\ConnectionName; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -13,6 +14,7 @@ use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Hypervel\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; use Hypervel\Database\Query\JoinClause; +use Hypervel\Support\Str; /** * @template TRelatedModel of Model @@ -71,11 +73,38 @@ public function match(array $models, EloquentCollection $results, string $relati public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder { + $query = parent::getRelationExistenceQuery($query, $parentQuery, $columns); + if ($this->isOneOfMany()) { $this->mergeOneOfManyJoinsTo($query); + + if ($parentQuery->getQuery()->from !== $query->getQuery()->from + && $parentQuery->getQuery()->from === $this->throughParent->getTable()) { + $this->qualifyOneOfManyJoinWithThroughParentAlias($query); + } } - return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + return $query; + } + + /** + * Point the one-of-many join at the aliased intermediate table of a self-referencing existence query. + * + * @param Builder $query + */ + protected function qualifyOneOfManyJoinWithThroughParentAlias(Builder $query): void + { + $joins = $query->getQuery()->joins; + + $alias = Str::afterLast($joins[0]->table, ' as '); + + $join = last($joins); + + foreach ($join->wheres as $key => $where) { + if (($where['second'] ?? null) === $this->getQualifiedFirstKeyName()) { + $join->wheres[$key]['second'] = $alias . '.' . $this->firstKey; + } + } } public function addOneOfManySubQueryConstraints(Builder $query, ?string $column = null, ?string $aggregate = null): void @@ -114,6 +143,18 @@ public function newRelatedInstanceFor(Model $parent): Model return $this->related->newInstance(); } + /** + * Determine if the model is the related instance of the relationship. + */ + public function is(?Model $model): bool + { + return ! is_null($model) + && ! is_null($this->getParentKey()) + && $this->related->getTable() === $model->getTable() + && ConnectionName::withoutWriteType($this->related->getConnectionName()) === ConnectionName::withoutWriteType($model->getConnectionName()) + && (clone $this->query)->whereKey($model->getKey())->exists(); + } + protected function getRelatedKeyFrom(Model $model): mixed { return $model->getAttribute($this->getForeignKeyName()); diff --git a/src/database/src/Eloquent/Relations/MorphOne.php b/src/database/src/Eloquent/Relations/MorphOne.php index 351cf76ff0..16e3dfab23 100644 --- a/src/database/src/Eloquent/Relations/MorphOne.php +++ b/src/database/src/Eloquent/Relations/MorphOne.php @@ -102,12 +102,14 @@ public function newRelatedInstanceFor(Model $parent): Model } /** - * Get the value of the model's foreign key. + * Get the foreign key when the model has the matching morph type. * * @param TRelatedModel $model */ protected function getRelatedKeyFrom(Model $model): mixed { - return $model->getAttribute($this->getForeignKeyName()); + return $model->getAttribute($this->getMorphType()) === $this->morphClass + ? $model->getAttribute($this->getForeignKeyName()) + : null; } } diff --git a/src/database/src/Eloquent/Relations/MorphOneOrMany.php b/src/database/src/Eloquent/Relations/MorphOneOrMany.php index 3065f76a6a..b3eb34eae7 100644 --- a/src/database/src/Eloquent/Relations/MorphOneOrMany.php +++ b/src/database/src/Eloquent/Relations/MorphOneOrMany.php @@ -70,8 +70,11 @@ public function addEagerConstraints(array $models): void */ public function forceCreate(array $attributes = []): Model { - $attributes[$this->getForeignKeyName()] = $this->getParentKey(); - $attributes[$this->getMorphType()] = $this->morphClass; + // Pending-attribute mutators may read the relationship keys. + $attributes = [ + $this->getForeignKeyName() => $this->getParentKey(), + $this->getMorphType() => $this->morphClass, + ] + array_merge($this->getQuery()->pendingAttributes, $attributes); return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); } diff --git a/src/database/src/Eloquent/Relations/MorphPivot.php b/src/database/src/Eloquent/Relations/MorphPivot.php index f157a77068..bb16aaf280 100644 --- a/src/database/src/Eloquent/Relations/MorphPivot.php +++ b/src/database/src/Eloquent/Relations/MorphPivot.php @@ -64,6 +64,8 @@ public function delete(): int return 0; } + $this->touchOwners(); + $query = $this->getDeleteQuery(); $query->where($this->morphType, $this->morphClass); diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 0c2662ee01..ba6973fe99 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -14,6 +14,7 @@ use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\BinaryParameter; use Hypervel\Database\Concerns\BuildsQueries; use Hypervel\Database\Concerns\BuildsWhereDateClauses; use Hypervel\Database\Concerns\ExplainsQueries; @@ -2747,9 +2748,15 @@ public function forPage(int $page, int $perPage = 15): static /** * Constrain the query to the previous "page" of results before a given ID. + * + * @param null|BinaryParameter|int|resource|string $lastId */ - public function forPageBeforeId(int $perPage = 15, string|int|null $lastId = 0, string $column = 'id'): static + public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static { + if (is_resource($lastId)) { + $lastId = BinaryParameter::fromStream($lastId); + } + $this->orders = $this->removeExistingOrdersFor($column); if (is_null($lastId)) { @@ -2764,9 +2771,15 @@ public function forPageBeforeId(int $perPage = 15, string|int|null $lastId = 0, /** * Constrain the query to the next "page" of results after a given ID. + * + * @param null|BinaryParameter|int|resource|string $lastId */ - public function forPageAfterId(int $perPage = 15, string|int|null $lastId = 0, string $column = 'id'): static + public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static { + if (is_resource($lastId)) { + $lastId = BinaryParameter::fromStream($lastId); + } + $this->orders = $this->removeExistingOrdersFor($column); if (is_null($lastId)) { diff --git a/src/docs/database.md b/src/docs/database.md index cda6734a48..973204b0bd 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -749,6 +749,26 @@ $device = Device::query()->find(new BinaryParameter($uuidBytes)); The wrapper only expresses binding intent. It does not encode or validate the value, and ordinary text strings should not be wrapped. +When using `chunkById` or `lazyById` with binary strings returned by the query builder, use an `afterQuery` callback to provide a binary cursor. Pass its name as the cursor alias so the original column remains unchanged: + +```php +use Hypervel\Database\BinaryParameter; +use Hypervel\Support\Collection; +use Hypervel\Support\Facades\DB; + +DB::table('files')->afterQuery(function (Collection $rows) { + foreach ($rows as $row) { + $row->cursor = is_resource($row->hash) + ? BinaryParameter::fromStream($row->hash) + : new BinaryParameter($row->hash); + } +})->chunkById(100, function (Collection $files) { + // Process the files... +}, 'hash', 'cursor'); +``` + +Hypervel cannot distinguish an unwrapped binary string from text. Eloquent attributes using the `AsBinary` cast and binary streams returned by PostgreSQL are handled automatically. + #### Running an Insert Statement diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 8988423a3b..f4bf2e193e 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -56,6 +56,12 @@ use Hypervel\Support\Facades\Storage; Storage::disk('local')->put('example.txt', 'Contents'); ``` +By default, the local driver creates its root directory when the disk is resolved. To defer creation until a write operation needs the directory, set the `lazy_root_creation` option to `true` in the disk's configuration: + +```php +'lazy_root_creation' => true, +``` + ### The Public Disk diff --git a/src/docs/responses.md b/src/docs/responses.md index 96e3391b44..4d110bd9dd 100644 --- a/src/docs/responses.md +++ b/src/docs/responses.md @@ -13,6 +13,7 @@ - [No Content Responses](#no-content-responses) - [View Responses](#view-responses) - [JSON Responses](#json-responses) + - [Markdown Responses](#markdown-responses) - [File Downloads](#file-downloads) - [File Responses](#file-responses) - [Streamed Responses](#streamed-responses) @@ -409,6 +410,21 @@ return response()->jsonp($callback, [ ]); ``` + +### Markdown Responses + +The `markdown` method may be used to return Markdown content with the `Content-Type` header set to `text/markdown`: + +```php +return response()->markdown("# Hello\n\nWorld"); +``` + +You may pass a custom HTTP status code and an array of additional headers as the second and third arguments: + +```php +return response()->markdown('# Not Found', 404, ['X-Custom-Header' => 'Value']); +``` + ### File Downloads diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index e3b22f8d04..7c5f525e27 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -392,6 +392,19 @@ Schedule::command('report:generate') ->onOneServer(); ``` +To apply `onOneServer` to all scheduled tasks, you may call the `alwaysOnOneServer` method in the `boot` method of your application's `AppServiceProvider`: + +```php +use Hypervel\Console\Scheduling\Schedule; + +public function boot(): void +{ + Schedule::alwaysOnOneServer(); +} +``` + +Scheduled callbacks without a [name](#naming-unique-jobs), including closures and invokable objects, are excluded and will continue to run on every server. + You may use the `useCache` method to override the configured cache store used by the scheduler to obtain the atomic locks necessary for single-server tasks: ```php diff --git a/src/events/README.md b/src/events/README.md index 397557ad65..ded3c48222 100644 --- a/src/events/README.md +++ b/src/events/README.md @@ -5,8 +5,8 @@ Events for Hypervel Documentation: https://hypervel.org/docs/events -Ported from: https://github.com/laravel/framework - ## Differences From Laravel Hypervel queued listeners accept the current array payload produced by the event dispatcher. Laravel's pre-2017 serialized-string compatibility path is intentionally omitted because Hypervel 0.4 has no legacy listener payloads and every supported producer uses the array form. + +Ported from: https://github.com/laravel/framework diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 1be29b8246..ee245b0dbb 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -387,7 +387,8 @@ public function createLocalDriver(array $config, string $name = 'local'): Filesy $config['root'], $visibility, $config['lock'] ?? LOCK_EX, - $links + $links, + lazyRootCreation: $config['lazy_root_creation'] ?? false, ); return (new LocalFilesystemAdapter( diff --git a/src/http-server/src/Server.php b/src/http-server/src/Server.php index 45869f25c9..a0645ec476 100644 --- a/src/http-server/src/Server.php +++ b/src/http-server/src/Server.php @@ -110,11 +110,13 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe // Dispatch through the Kernel (global middleware → Router → response) $response = $this->kernel->handle($request); - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception = $throwable; - $response = new SymfonyResponse('Internal Server Error', 500); + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception = $throwable; + $response = new SymfonyResponse('Internal Server Error', 500); + } } finally { if (isset($request) && $cancellation === null) { try { @@ -126,10 +128,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe server: $this->serverName )); } - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } @@ -145,10 +149,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe protocol: is_string($protocol) ? $protocol : 'HTTP/1.1', request: $request ?? null, ); - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } @@ -162,10 +168,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe server: $this->serverName )); } - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } @@ -173,10 +181,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe if (isset($request) && $response !== null && $cancellation === null) { try { $this->kernel->terminate($request, $response); - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } @@ -190,10 +200,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe server: $this->serverName ))); } - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } diff --git a/src/object-pool/src/Lease.php b/src/object-pool/src/Lease.php index 6e1639c92d..62b6962c94 100644 --- a/src/object-pool/src/Lease.php +++ b/src/object-pool/src/Lease.php @@ -6,8 +6,8 @@ use Closure; use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract; +use Hypervel\Coroutine\Coroutine; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class Lease @@ -53,21 +53,14 @@ public function release(): void if ($this->releaseCallback !== null) { ($this->releaseCallback)($this->object); } - } catch (CanceledException $cancellation) { - try { - $this->pool->discard($this->object); - } catch (CanceledException) { - } catch (Throwable $exception) { - PoolErrorReporter::report($exception); - } - - throw $cancellation; } catch (Throwable $exception) { try { $this->pool->discard($this->object); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $discardException) { + if (Coroutine::causedByCancellation($discardException) && ! Coroutine::causedByCancellation($exception)) { + throw $discardException; + } + PoolErrorReporter::report($discardException); } @@ -97,9 +90,11 @@ public function releaseAfterFailure(Throwable $failure): never { try { $this->release(); - } catch (CanceledException $cancellation) { - throw $failure instanceof CanceledException ? $failure : $cancellation; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception) && ! Coroutine::causedByCancellation($failure)) { + throw $exception; + } + PoolErrorReporter::report($exception); } @@ -113,9 +108,11 @@ public function discardAfterFailure(Throwable $failure): never { try { $this->discard(); - } catch (CanceledException $cancellation) { - throw $failure instanceof CanceledException ? $failure : $cancellation; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception) && ! Coroutine::causedByCancellation($failure)) { + throw $exception; + } + PoolErrorReporter::report($exception); } diff --git a/src/object-pool/src/ObjectPool.php b/src/object-pool/src/ObjectPool.php index 020460802d..0c3f026a64 100644 --- a/src/object-pool/src/ObjectPool.php +++ b/src/object-pool/src/ObjectPool.php @@ -6,11 +6,11 @@ use Closure; use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\PoolChannel; use Hypervel\ObjectPool\Exceptions\PoolClosedException; use Hypervel\ObjectPool\Exceptions\PoolExhaustedException; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -179,8 +179,16 @@ public function close(): void while (($object = $this->channel->pop()) !== false) { try { $this->destroyObject($object); - } catch (CanceledException $exception) { - $cancellation ??= $exception; + } catch (Throwable $exception) { + if (! Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + if ($cancellation === null) { + $cancellation = $exception; + } else { + PoolErrorReporter::report($exception); + } } } @@ -320,9 +328,11 @@ protected function destroyObject(object $object): void if ($this->destroyCallback !== null) { ($this->destroyCallback)($object); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + PoolErrorReporter::report($exception); } finally { unset( diff --git a/src/queue/src/Concerns/StagesOutboxJobs.php b/src/queue/src/Concerns/StagesOutboxJobs.php index 35c2406fbb..cde9962fe9 100644 --- a/src/queue/src/Concerns/StagesOutboxJobs.php +++ b/src/queue/src/Concerns/StagesOutboxJobs.php @@ -8,6 +8,7 @@ use DateTimeInterface; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Queue\ShouldQueueViaOutbox; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\InvalidPayloadException; @@ -15,7 +16,6 @@ use Hypervel\Support\Str; use JsonException; use LogicException; -use Swoole\Coroutine\CanceledException; use Throwable; trait StagesOutboxJobs @@ -104,9 +104,11 @@ protected function enqueueViaOutbox(object|string $job, string $payload, ?string $row = $this->prepareOutboxRow($job, $payload, $queue, $delay); $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); $dispatcher->store($connection, [$row], [$job]); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception); throw $exception; @@ -186,9 +188,11 @@ protected function stageOutboxJobs(array $jobs, mixed $data, ?string $queue): ar throw $failure; } } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $failure = $this->notifyBatchMembers($prepared, function (array $member) use ($queue, $exception): void { $this->raiseJobQueueingFailedEvent($queue, $member['job'], $member['payload'], $member['delay'], $exception); }); diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index b533b63346..ac189dc2fa 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Queue\PublishesOutboxMessages; use Hypervel\Contracts\Queue\Queue as QueueContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; @@ -27,7 +28,6 @@ use Hypervel\Queue\Outbox\OutboxMessage; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -426,9 +426,11 @@ protected function enqueueBatch(array $jobs, ?string $queue): mixed } $result = true; - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + throw $this->notifyBatchMembers( $jobs, fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception), @@ -500,9 +502,11 @@ public function publishOutboxMessages(array $messages): OutboxBatchResult } $this->insertDatabaseRows($connection, $this->table, $rows); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return new OutboxBatchResult(failure: $exception); } @@ -590,9 +594,11 @@ public function pop(UnitEnum|string|null $queue = null): ?Job $this->connectionName, $queue ))->fail($exception); - } catch (CanceledException $cancellation) { - throw $cancellation; - } catch (Throwable) { + } catch (Throwable $cleanupException) { + if (Coroutine::causedByCancellation($cleanupException)) { + throw $cleanupException; + } + // Preserve the original reservation failure if failing the job also fails. } } diff --git a/src/queue/src/Jobs/SqsJob.php b/src/queue/src/Jobs/SqsJob.php index 8f8dbb8f11..31a59541a5 100644 --- a/src/queue/src/Jobs/SqsJob.php +++ b/src/queue/src/Jobs/SqsJob.php @@ -8,10 +8,10 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Container\Container; +use Hypervel\Coroutine\Coroutine; use Hypervel\ObjectPool\PoolErrorReporter; use Hypervel\Support\Arr; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class SqsJob extends Job @@ -79,17 +79,21 @@ public function delete(): void try { $this->releasePoolLease(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $releaseException = $exception; } try { $this->deleteOverflowPayload(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $cleanupException) { + if (Coroutine::causedByCancellation($cleanupException)) { + throw $cleanupException; + } + if ($releaseException !== null) { PoolErrorReporter::report($cleanupException); diff --git a/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php b/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php index be8fa02f75..f927c3dd57 100644 --- a/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php +++ b/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php @@ -5,10 +5,10 @@ namespace Hypervel\Queue\Outbox\Concerns; use Hypervel\Contracts\Queue\Queue; +use Hypervel\Coroutine\Coroutine; use Hypervel\Queue\Outbox\OutboxBatchResult; use Hypervel\Queue\Outbox\OutboxMessage; use Hypervel\Support\InteractsWithTime; -use Swoole\Coroutine\CanceledException; use Throwable; trait PublishesOutboxMessagesSequentially @@ -40,9 +40,11 @@ protected function publishOutboxMessagesSequentially(Queue $queue, array $messag $queue->pushRaw($message->payload, $message->queue, $message->options); $accepted[] = $message->id; } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return new OutboxBatchResult($accepted, $deferred, $exception); } diff --git a/src/queue/src/Outbox/OutboxRelay.php b/src/queue/src/Outbox/OutboxRelay.php index 9659b14936..034b6807ae 100644 --- a/src/queue/src/Outbox/OutboxRelay.php +++ b/src/queue/src/Outbox/OutboxRelay.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\Factory; use Hypervel\Contracts\Queue\PublishesOutboxMessages; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Exceptions\ChildCancellationException; use Hypervel\Coroutine\Waiter; use Hypervel\Queue\Events\OutboxBatchDelivered; @@ -16,7 +17,6 @@ use Hypervel\Queue\Outbox\Concerns\PublishesOutboxMessagesSequentially; use LogicException; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class OutboxRelay @@ -71,9 +71,11 @@ public function deliver(string $database, array $messages): bool return $queue instanceof PublishesOutboxMessages ? $queue->publishOutboxMessages($group) : $this->publishOutboxMessagesSequentially($queue, $group); - } catch (CanceledException|ChildCancellationException $exception) { - throw $exception; } catch (Throwable $exception) { + if ($exception instanceof ChildCancellationException || Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return new OutboxBatchResult(failure: $exception); } }); @@ -86,9 +88,11 @@ public function deliver(string $database, array $messages): bool } return null; - } catch (CanceledException|ChildCancellationException $exception) { - throw $exception; } catch (Throwable $exception) { + if ($exception instanceof ChildCancellationException || Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return $exception; } }); @@ -185,9 +189,11 @@ protected function reportOutcome( try { $this->events->dispatch($event); - } catch (CanceledException|ChildCancellationException $exception) { - throw $exception; } catch (Throwable $exception) { + if ($exception instanceof ChildCancellationException || Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $listenerFailure = $exception; } } diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 6e98a26324..3c35517ff3 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -15,6 +15,7 @@ use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Queue\ShouldBeEncrypted; use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Queue\Attributes\Backoff; @@ -441,9 +442,11 @@ protected function enqueueUsing(object|string $job, string $payload, ?string $qu { try { $stageViaOutbox = $this->shouldStageViaOutbox($job); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception); throw $exception; @@ -558,9 +561,11 @@ protected function enqueueNow(object|string $job, string $payload, ?string $queu $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); $storedPayload = $this->preparePayloadForPush($job, $payload, $queue); $jobId = $callback($this, $storedPayload, $queue, $delay); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception); throw $exception; @@ -571,17 +576,21 @@ protected function enqueueNow(object|string $job, string $payload, ?string $queu try { $this->handlePayloadPushed($storedPayload, $queue); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception = $throwable; } try { $this->raiseJobQueuedEvent($queue, $jobId, $job, $payload, $delay); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } @@ -622,9 +631,11 @@ protected function notifyBatchMembers(array $members, Closure ...$notifications) foreach ($notifications as $notification) { try { $notification($member); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php index ad94e2ca3d..d2de1d46aa 100644 --- a/src/queue/src/QueuePoolProxy.php +++ b/src/queue/src/QueuePoolProxy.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Queue\PublishesOutboxMessages; use Hypervel\Contracts\Queue\Queue as QueueContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolDefinition; @@ -298,14 +299,18 @@ public function pop(UnitEnum|string|null $queue = null, int $index = 0): ?Job try { return $job->withPoolLease($lease); - } catch (CanceledException $attachmentCancellation) { - $lease->discardAfterFailure($attachmentCancellation); } catch (Throwable $attachmentException) { + if (Coroutine::causedByCancellation($attachmentException)) { + $lease->discardAfterFailure($attachmentException); + } + try { $job->release(0); - } catch (CanceledException $recoveryCancellation) { - $lease->discardAfterFailure($recoveryCancellation); } catch (Throwable $recoveryException) { + if (Coroutine::causedByCancellation($recoveryException)) { + $lease->discardAfterFailure($recoveryException); + } + PoolErrorReporter::report($recoveryException); $lease->discardAfterFailure($attachmentException); diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 4e02a5416d..7069558d39 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Queue\PublishesOutboxMessages; use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Redis\Factory as Redis; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\Jobs\RedisJob; @@ -22,7 +23,6 @@ use Hypervel\Support\Collection; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -433,9 +433,11 @@ protected function enqueueBatch(array $jobs, ?string $queue): void $this->isCluster ??= $connection->isCluster(); $queueKey = $this->getQueueRedisKey($queue); $this->pushRawBatch($connection, $queueKey, $arguments); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + throw $this->notifyBatchMembers( $jobs, fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception), @@ -503,9 +505,11 @@ public function publishOutboxMessages(array $messages): OutboxBatchResult throw $failure; } } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return new OutboxBatchResult($accepted, failure: $exception); } diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index bddbb46391..f84ecfc1de 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -16,6 +16,7 @@ use Hypervel\Contracts\Queue\Job as JobContract; use Hypervel\Contracts\Queue\PublishesOutboxMessages; use Hypervel\Contracts\Queue\Queue as QueueContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\ObjectPool\PoolErrorReporter; use Hypervel\Queue\Jobs\SqsJob; @@ -26,7 +27,6 @@ use Hypervel\Support\Str; use LogicException; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -257,15 +257,16 @@ public function pushRaw(string $payload, UnitEnum|string|null $queue = null, arr try { $this->storeOverflowPayload($store, $path, $overflowPayload); - } catch (CanceledException $cancellation) { + } catch (Throwable $exception) { try { $this->cleanupOverflowPayloads($store, [$path]); - } catch (CanceledException) { - } + } catch (Throwable $cleanupException) { + if (! Coroutine::causedByCancellation($exception)) { + throw $cleanupException; + } - throw $cancellation; - } catch (Throwable $exception) { - $this->cleanupOverflowPayloads($store, [$path]); + PoolErrorReporter::report($cleanupException); + } throw $exception; } @@ -455,9 +456,11 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void $queueUrl = $this->getQueue($queue); $store = $overflow === [] ? null : $this->overflowStore(); $chunks = $this->chunkBatchEntries($entries); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); throw $exception; @@ -475,9 +478,11 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void if ($store !== null) { $this->storeBatchOverflowPayloads($chunk, $overflow, $store); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); throw $exception; @@ -490,9 +495,11 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void 'QueueUrl' => $queueUrl, 'Entries' => $chunk, ]); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); throw $exception; @@ -545,9 +552,11 @@ function (array $success) use ($messages, $queue): void { try { $this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $notificationFailure ??= $throwable; } @@ -639,9 +648,11 @@ public function publishOutboxMessages(array $messages): OutboxBatchResult } } } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return new OutboxBatchResult($accepted, failure: $exception); } @@ -801,18 +812,17 @@ protected function storeBatchOverflowPayloads(array $chunk, array $overflow, Cac $paths[] = $payload['path']; $this->storeOverflowPayload($store, $payload['path'], $payload['payload']); } - } catch (CanceledException $cancellation) { + } catch (Throwable $exception) { if ($paths !== []) { try { $this->cleanupOverflowPayloads($store, $paths); - } catch (CanceledException) { - } - } + } catch (Throwable $cleanupException) { + if (! Coroutine::causedByCancellation($exception)) { + throw $cleanupException; + } - throw $cancellation; - } catch (Throwable $exception) { - if ($paths !== []) { - $this->cleanupOverflowPayloads($store, $paths); + PoolErrorReporter::report($cleanupException); + } } throw $exception; @@ -833,10 +843,12 @@ protected function cleanupOverflowPayloads(CacheRepository $store, array $paths) if (! $store->forget($path)) { throw new RuntimeException("Unable to delete the SQS overflow payload [{$path}]."); } - } catch (CanceledException $exception) { - $cancellation ??= $exception; } catch (Throwable $exception) { - PoolErrorReporter::report($exception); + if ($cancellation === null && Coroutine::causedByCancellation($exception)) { + $cancellation = $exception; + } else { + PoolErrorReporter::report($exception); + } } } diff --git a/src/routing/composer.json b/src/routing/composer.json index bcc2539ed8..79caa90664 100644 --- a/src/routing/composer.json +++ b/src/routing/composer.json @@ -33,6 +33,7 @@ "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", diff --git a/src/routing/src/ResponseFactory.php b/src/routing/src/ResponseFactory.php index 02f09bbb6a..066bb12f99 100644 --- a/src/routing/src/ResponseFactory.php +++ b/src/routing/src/ResponseFactory.php @@ -8,6 +8,7 @@ use Closure; use Hypervel\Contracts\Routing\ResponseFactory as FactoryContract; use Hypervel\Contracts\View\Factory as ViewFactory; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\JsonResponse; use Hypervel\Http\RedirectResponse; @@ -19,7 +20,6 @@ use Hypervel\Support\Traits\Macroable; use ReflectionFunction; use SplFileInfo; -use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\StreamedJsonResponse; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -92,6 +92,16 @@ public function jsonp(string $callback, mixed $data = [], int $status = 200, arr return $this->json($data, $status, $headers, $options)->setCallback($callback); } + /** + * Create a new Markdown response instance. + */ + public function markdown(string $content = '', int $status = 200, array $headers = []): Response + { + return $this->make($content, $status, array_merge($headers, [ + 'Content-Type' => 'text/markdown', + ])); + } + /** * Create a new event stream response. * @@ -109,9 +119,11 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven if (filled($endStreamWith)) { yield $this->formatStreamedEvent($endStreamWith); } - } catch (CanceledException $e) { - throw $e; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + report($e); } }, 200, array_merge($headers, [ @@ -191,9 +203,11 @@ public function streamDownload(callable $callback, ?string $name = null, array $ $withWrappedException = function () use ($callback) { try { $callback(); - } catch (CanceledException $e) { - throw $e; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + throw new StreamedResponseException($e); } }; diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index b5ac8b6a17..44315156dc 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -34,7 +34,7 @@ * @method static \Hypervel\Database\Connection reconnect(\UnitEnum|string|null $name = null) * @method static void releaseIdleConnections() * @method static \Hypervel\Database\DatabaseManager setApplication(\Hypervel\Contracts\Foundation\Application $app) - * @method static void setDefaultConnection(string|null $name) + * @method static void setDefaultConnection(\UnitEnum|string|null $name) * @method static void setReconnector(callable $reconnector) * @method static string[] supportedDrivers() * @method static mixed usingConnection(\UnitEnum|string $name, callable $callback) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 42e1437bb9..ca7102b5b8 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -153,12 +153,16 @@ public static function fakeFor(callable $callable, array $jobsToFake = []): mixe { $originalQueueManager = static::getFacadeRoot(); - static::fake($jobsToFake); + $fake = static::fake($jobsToFake); try { return $callable(); } finally { - static::swap($originalQueueManager); + try { + static::releaseScopedFakeLocks($fake); + } finally { + static::swap($originalQueueManager); + } } } @@ -169,12 +173,31 @@ public static function fakeExceptFor(callable $callable, array $jobsToAllow = [] { $originalQueueManager = static::getFacadeRoot(); - static::fakeExcept($jobsToAllow); + $fake = static::fakeExcept($jobsToAllow); try { return $callable(); } finally { - static::swap($originalQueueManager); + try { + static::releaseScopedFakeLocks($fake); + } finally { + static::swap($originalQueueManager); + } + } + } + + /** + * Release the dispatch locks owned by a scoped fake and its replacement. + */ + protected static function releaseScopedFakeLocks(QueueFake $fake): void + { + $fake->releaseUniqueJobLocks(); + + // A fake installed inside the callback owns dispatches made after it replaced this one. + $active = static::getFacadeRoot(); + + if ($active instanceof QueueFake && $active !== $fake) { + $active->releaseUniqueJobLocks(); } } diff --git a/src/support/src/Facades/Response.php b/src/support/src/Facades/Response.php index 0d9659cdba..26560446f1 100644 --- a/src/support/src/Facades/Response.php +++ b/src/support/src/Facades/Response.php @@ -17,6 +17,7 @@ * @method static \Hypervel\Http\JsonResponse jsonp(string $callback, mixed $data = [], int $status = 200, array $headers = [], int $options = 0) * @method static void macro(string $name, callable|object $macro) * @method static \Hypervel\Http\Response make(mixed $content = '', int $status = 200, array $headers = []) + * @method static \Hypervel\Http\Response markdown(string $content = '', int $status = 200, array $headers = []) * @method static void mixin(object $mixin, bool $replace = true) * @method static \Hypervel\Http\Response noContent(int $status = 204, array $headers = []) * @method static \Hypervel\Http\RedirectResponse redirectGuest(string $path, int $status = 302, array $headers = [], bool|null $secure = null) diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php index 3b9e420c58..ca058b41f4 100644 --- a/src/support/src/Facades/Schedule.php +++ b/src/support/src/Facades/Schedule.php @@ -7,6 +7,7 @@ use Hypervel\Console\Scheduling\Schedule as ConsoleSchedule; /** + * @method static void alwaysOnOneServer(bool $value = true) * @method static \Hypervel\Console\Scheduling\CallbackEvent call(callable|array|string $callback, array $parameters = []) * @method static \Hypervel\Console\Scheduling\Event command(\Symfony\Component\Console\Command\Command|string $command, array $parameters = []) * @method static string compileArrayInput(string|int $key, array $value) diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php index 00b42b714b..fe5e6bea42 100644 --- a/src/support/src/Testing/Fakes/QueueFake.php +++ b/src/support/src/Testing/Fakes/QueueFake.php @@ -9,13 +9,10 @@ use DateInterval; use DateTimeInterface; use Hypervel\Bus\DispatchLockContext; -use Hypervel\Bus\UniqueLock; -use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Queue\Factory as FactoryContract; use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Queue\Queue; -use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Events\CallQueuedListener; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\ReadsQueueAttributes; @@ -33,6 +30,7 @@ /** * @phpstan-type RawPushType array{payload: string, queue: ?string, options: array} + * @phpstan-import-type LockSnapshot from DispatchLockContext */ class QueueFake extends QueueManager implements Fake, Queue { @@ -68,9 +66,11 @@ class QueueFake extends QueueManager implements Fake, Queue protected array $rawPushes = []; /** - * All of the unique jobs that were pushed. + * The dispatch locks owned by faked jobs. + * + * @var list */ - protected array $uniqueJobs = []; + protected array $dispatchLocks = []; /** * All of the jobs that have been marked as reserved. @@ -621,11 +621,12 @@ protected function enqueueUsing( 'delay' => $delay, ]; - if ($job instanceof ShouldBeUnique) { - $this->uniqueJobs[] = $job; - } - if (is_object($job)) { + // Retain acquired ownership; reconstructing locks from jobs can release another dispatch's lock. + if (($snapshot = DispatchLockContext::snapshot($job)) !== null) { + $this->dispatchLocks[] = $snapshot; + } + DispatchLockContext::accept($job); } @@ -808,17 +809,15 @@ protected function serializeAndRestoreJob(mixed $job): mixed } /** - * Release the locks for all unique jobs that were pushed. + * Release the dispatch locks owned by faked jobs. */ public function releaseUniqueJobLocks(): void { - $lock = new UniqueLock($this->app->make(Cache::class)); - - foreach ($this->uniqueJobs as $job) { - $lock->release($job); + foreach ($this->dispatchLocks as $snapshot) { + DispatchLockContext::releaseSnapshot($snapshot); } - $this->uniqueJobs = []; + $this->dispatchLocks = []; } /** diff --git a/src/support/src/functions.php b/src/support/src/functions.php index 81c8dceb22..97db3fd051 100644 --- a/src/support/src/functions.php +++ b/src/support/src/functions.php @@ -88,6 +88,7 @@ function milliseconds(int|float $milliseconds): CarbonInterval */ function seconds(int|float $seconds): CarbonInterval { + // Build durations numerically: CarbonInterval::make() rejects floats printed in scientific notation. if (is_int($seconds)) { return CarbonInterval::seconds($seconds); } diff --git a/tests/Ai/StreamCancellationTest.php b/tests/Ai/StreamCancellationTest.php index 50738050eb..9924ee259c 100644 --- a/tests/Ai/StreamCancellationTest.php +++ b/tests/Ai/StreamCancellationTest.php @@ -12,26 +12,32 @@ use Hypervel\Support\Facades\Exceptions; use Hypervel\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; class StreamCancellationTest extends TestCase { - #[DataProvider('protocols')] - public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $protocol): void + #[DataProvider('cancellationProtocols')] + public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $protocol, bool $cleanupThrows): void { Exceptions::fake(); $cancellation = new CanceledException('The client disconnected.'); + $cleanup = new RuntimeException('Producer cleanup failed.'); $cleanedUp = false; $completed = false; $caught = null; - $stream = (new StreamableAgentResponse('invocation-1', function () use ($cancellation, &$cleanedUp): Generator { + $stream = (new StreamableAgentResponse('invocation-1', function () use ($cancellation, $cleanup, $cleanupThrows, &$cleanedUp): Generator { try { yield new StreamStart('message-1', 'openai', 'gpt-5', time()); throw $cancellation; } finally { $cleanedUp = true; + + if ($cleanupThrows) { + throw $cleanup; + } } }))->catch(function (Throwable $exception) use (&$caught): void { $caught = $exception; @@ -55,12 +61,16 @@ public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $ return true; }); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $thrown = $exception; } - $this->assertSame($cancellation, $thrown); - $this->assertSame($cancellation, $caught); + $this->assertSame($cleanupThrows ? $cleanup : $cancellation, $thrown); + $this->assertSame($cleanupThrows ? $cleanup : $cancellation, $caught); + + if ($cleanupThrows) { + $this->assertSame($cancellation, $cleanup->getPrevious()); + } $this->assertTrue($cleanedUp); $this->assertFalse($completed); $this->assertNotEmpty($chunks); @@ -68,6 +78,21 @@ public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $ Exceptions::assertNothingReported(); } + /** + * Provide cancellation with and without a producer cleanup failure for each protocol. + */ + public static function cancellationProtocols(): array + { + $cases = []; + + foreach (self::protocols() as $name => [$protocol]) { + $cases[$name] = [$protocol, false]; + $cases[$name . ' cleanup'] = [$protocol, true]; + } + + return $cases; + } + #[DataProvider('protocols')] public function testAFailedWriteDisposesTheProducerWithoutResumingIt(?string $protocol): void { diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index 33725591d9..0ea33f55d7 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -836,6 +836,27 @@ public static function invalidRememberCookieHashes(): array ]; } + public function testHashPasswordForCookieAcceptsNullPassword(): void + { + $guard = $this->getGuard(); + $deprecations = []; + + set_error_handler(function (int $level, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, E_DEPRECATED); + + try { + $hash = $guard->hashPasswordForCookie(null); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); + $this->assertSame($guard->hashPasswordForCookie(''), $hash); + } + public function testLoginOnceSetsUser(): void { [$guard, $provider, $session] = $this->getRealGuard(); diff --git a/tests/Console/Scheduling/ScheduleTest.php b/tests/Console/Scheduling/ScheduleTest.php index 9099640a0b..7f1097d0b8 100644 --- a/tests/Console/Scheduling/ScheduleTest.php +++ b/tests/Console/Scheduling/ScheduleTest.php @@ -28,6 +28,7 @@ use Mockery as m; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; enum ScheduleTestQueueStringEnum: string { @@ -231,8 +232,11 @@ public function testItCanFilterEventsByEnvironments(): void $this->assertSame('0 * * * *', $filteredEvents[2]->expression); } - public function testDueEventsAtUsesGivenTime(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDueEventsAtUsesGivenTime(bool $alwaysOnOneServer): void { + Schedule::alwaysOnOneServer($alwaysOnOneServer); $app = m::mock(ApplicationContract::class); $app->shouldReceive('isDownForMaintenance')->andReturn(false); $app->shouldReceive('environment')->andReturn('production'); @@ -244,7 +248,9 @@ public function testDueEventsAtUsesGivenTime(): void $schedule->command('reports:generate')->dailyAt('13:00'); self::assertCount(0, $schedule->dueEventsAt($app, CarbonImmutable::parse('2026-05-29 12:59:59'))); - self::assertCount(1, $schedule->dueEventsAt($app, CarbonImmutable::parse('2026-05-29 13:00:00'))); + $dueEvents = $schedule->dueEventsAt($app, CarbonImmutable::parse('2026-05-29 13:00:00')); + self::assertCount(1, $dueEvents); + self::assertSame($alwaysOnOneServer, $dueEvents->first()->onOneServer); } finally { CarbonImmutable::setTestNow(); } @@ -544,6 +550,34 @@ public function testItCanAddAttributesToPendingEvents(): void $this->assertSame([], $events[1]->attributes); } + public function testEventsDoNotRunOnOneServerByDefault(): void + { + $schedule = new Schedule; + + $schedule->command('inspire')->everyMinute(); + + $this->assertFalse($schedule->events()[0]->onOneServer); + } + + public function testEveryEventMayRunOnOneServer(): void + { + Schedule::alwaysOnOneServer(); + + $schedule = new Schedule; + + $schedule->command('inspire')->everyMinute(); + $schedule->job(JobToTestWithSchedule::class)->daily(); + $schedule->call(fn () => null)->name('prune')->hourly(); + $schedule->call(fn () => null)->hourly(); + + [$command, $job, $closure, $unnamedClosure] = $schedule->events(); + + $this->assertTrue($command->onOneServer); + $this->assertTrue($job->onOneServer); + $this->assertTrue($closure->onOneServer); + $this->assertFalse($unnamedClosure->onOneServer); + } + public function testWithoutInterruptionPollingDisablesPauseAndInterruptChecks(): void { Schedule::withoutInterruptionPolling(); @@ -552,14 +586,16 @@ public function testWithoutInterruptionPollingDisablesPauseAndInterruptChecks(): $this->assertFalse(Schedule::$interruptible); } - public function testFlushStateResetsPauseAndInterruptChecks(): void + public function testFlushStateResetsSchedulerDefaults(): void { Schedule::withoutInterruptionPolling(); + Schedule::alwaysOnOneServer(); Schedule::flushState(); $this->assertTrue(Schedule::$pausable); $this->assertTrue(Schedule::$interruptible); + $this->assertFalse(Schedule::$alwaysOnOneServer); } } diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 7f49a2a06c..44e3189e2e 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -13,6 +13,7 @@ use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Casts\AsBinary; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\ModelNotFoundException; @@ -759,6 +760,98 @@ public function testLazyByIdIsLazy(): void $this->assertSame([[]], $this->pagedBindings($connection)); } + public function testChunkByIdUsesTheRawKeyOfCastModels(): void + { + [$connection, $builder, $ids] = $this->newBinaryUuidPagedBuilder(); + + $chunks = []; + $builder->chunkById(2, function (Collection $results) use (&$chunks): bool { + $chunks[] = $results->pluck('id')->all(); + + return count($chunks) < 3; + }); + + $this->assertSame([[$ids[0], $ids[1]], [$ids[2]]], $chunks); + $this->assertEquals([[], [new BinaryParameter(hex2bin(str_replace('-', '', $ids[1])))]], $this->pagedBindings($connection)); + } + + public function testLazyByIdUsesTheRawKeyOfCastModels(): void + { + [$connection, $builder, $ids] = $this->newBinaryUuidPagedBuilder(); + + $this->assertSame($ids, $builder->lazyById(2)->take(4)->map(fn (BinaryUuidStub $model): string => $model->id)->all()); + $this->assertEquals([[], [new BinaryParameter(hex2bin(str_replace('-', '', $ids[1])))]], $this->pagedBindings($connection)); + } + + #[TestWith(['chunkById', false])] + #[TestWith(['lazyById', false])] + #[TestWith(['chunkById', true])] + #[TestWith(['lazyById', true])] + public function testPagingUsesCursorAliasesAddedAfterHydration(string $method, bool $accessor): void + { + [, $builder] = $this->newPagedBuilder([1, 2, 3]); + + if ($accessor) { + $builder->setModel(new CursorAliasStub); + } else { + $builder->afterQuery(static function (Collection $models): void { + foreach ($models as $model) { + $model->setAttribute('cursor', $model->getRawOriginal('id')); + } + }); + } + + $actual = []; + + if ($method === 'lazyById') { + $actual = $builder->lazyById(2, 'id', 'cursor')->take(4)->pluck('id')->all(); + } else { + $builder->chunkById(2, function (Collection $models) use (&$actual): bool { + array_push($actual, ...$models->pluck('id')->all()); + + return count($actual) < 4; + }, 'id', 'cursor'); + } + + $this->assertSame([1, 2, 3], $actual); + } + + #[TestWith(['chunkById', 'id'])] + #[TestWith(['lazyById', 'id'])] + #[TestWith(['chunkById', 'table.id'])] + #[TestWith(['lazyById', 'table.id'])] + #[TestWith(['chunkById', 't.id'])] + #[TestWith(['lazyById', 't.id'])] + #[TestWith(['chunkById', 't.id', true])] + public function testPagingPreservesBinaryBindingsForSelectedAliases(string $method, string $column, bool $castAlias = false): void + { + [$connection, $builder, $ids] = $this->newBinaryUuidPagedBuilder(); + $builder->select(['id', 'id as cursor']); + + if ($column === 't.id') { + $builder->from('table as t'); + } + + if ($castAlias) { + $builder->withCasts(['cursor' => AsBinary::uuid()]); + } + + $actual = []; + + if ($method === 'lazyById') { + $actual = $builder->lazyById(2, $column, 'cursor')->take(4)->pluck('id')->all(); + } else { + $builder->chunkById(2, function (Collection $models) use (&$actual): bool { + array_push($actual, ...$models->pluck('id')->all()); + + return count($actual) < 4; + }, $column, 'cursor'); + } + + $this->assertSame($ids, $actual); + $this->assertEquals([[], [new BinaryParameter(hex2bin(str_replace('-', '', $ids[1])))]], $this->pagedBindings($connection)); + } + public function testPluckReturnsTheMutatedAttributesOfAModel(): void { $builder = $this->getBuilder(); @@ -4015,6 +4108,33 @@ protected function newPagedBuilder(array $ids, string $column = 'id'): array return [$connection, $this->newBuilder($connection)]; } + /** + * Create a builder for paged models with binary UUID keys. + * + * @return array{SQLiteConnection, Builder, list} + */ + protected function newBinaryUuidPagedBuilder(): array + { + $ids = [ + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000003', + ]; + + $connection = $this->useInMemoryConnection(); + $connection->statement('create table "table" ("id" blob primary key)'); + + foreach ($ids as $id) { + $connection->table('table')->insert(['id' => new BinaryParameter(hex2bin(str_replace('-', '', $id)))]); + } + + $connection->enableQueryLog(); + + $builder = (new Builder($connection->query()))->setModel(new BinaryUuidStub); + + return [$connection, $builder, $ids]; + } + /** * Get the bindings of each logged query. */ @@ -4061,6 +4181,34 @@ class Stub extends Model protected ?string $table = 'table'; } +class CursorAliasStub extends Stub +{ + /** + * Return the raw key as an explicit paging cursor. + */ + public function getCursorAttribute(): mixed + { + return $this->getRawOriginal('id'); + } +} + +class BinaryUuidStub extends Model +{ + protected ?string $table = 'table'; + + protected string $keyType = 'string'; + + public bool $incrementing = false; + + /** + * Get the attributes that should be cast. + */ + protected function casts(): array + { + return ['id' => AsBinary::uuid()]; + } +} + class FreshQueryBuilder extends Builder { } diff --git a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php index a3e45ef991..fbf2f834ff 100644 --- a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php +++ b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php @@ -48,6 +48,10 @@ public function testIsComparesKeysTableAndConnection(string $type, mixed $parent ->setConnection($connection) ->forceFill([$relatedKeyAttribute => $relatedKey]); + if ($relation instanceof MorphOne) { + $model->setAttribute($relation->getMorphType(), $relation->getMorphClass()); + } + $this->assertSame($expected, $relation->is($model)); $this->assertSame(! $expected, $relation->isNot($model)); } @@ -81,6 +85,20 @@ public static function comparisons(): Generator } } + #[DataProvider('relationTypes')] + public function testUnrelatedModelsAreRejectedBeforeReadingMissingKeys(string $type): void + { + Model::preventAccessingMissingAttributes(); + [$relation] = $this->newRelation($type, 1); + + foreach ([['other_table', 'connection'], ['table', 'other_connection']] as [$table, $connection]) { + $model = (new ComparesRelatedModelsStub)->setTable($table)->newFromBuilder([], $connection); + + $this->assertFalse($relation->is($model)); + $this->assertTrue($relation->isNot($model)); + } + } + /** * Create a relation of the given type and the related key attribute it compares. * diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php index fbb0c9d284..a8ef664b6a 100755 --- a/tests/Database/DatabaseEloquentHasOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -324,8 +324,11 @@ public function testIsMethod(): void $login1 = $user->latest_login()->create(); $login2 = $user->latest_login()->create(); - $this->assertFalse($user->latest_login()->is($login1)); - $this->assertTrue($user->latest_login()->is($login2)); + $relation = $user->latest_login(); + + $this->assertFalse($relation->is($login1)); + $this->assertTrue($relation->is($login2)); + $this->assertSame($login2->id, $relation->first()->id); } public function testIsNotMethod(): void diff --git a/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php index d29495ae46..63b8447483 100755 --- a/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php +++ b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php @@ -6,7 +6,9 @@ use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; class DatabaseEloquentHasOneOrManyWithAttributesTest extends TestCase { @@ -288,6 +290,78 @@ public function testHasManyAddsCastedAttributes(): void $this->assertSame($parentId, $relatedModel->parent_id); $this->assertTrue($relatedModel->is_admin); } + + public function testHasManyForceCreateAddsAttributes(): void + { + $this->createTable(); + + $parent = new RelatedWithAttributesModel; + $parent->id = 123; + + $relatedModel = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes(['is_admin' => true]) + ->forceCreate(); + + $this->assertSame(123, $relatedModel->parent_id); + $this->assertTrue($relatedModel->fresh()->is_admin); + } + + public function testMorphManyForceCreateAddsAttributes(): void + { + $this->createTable(); + + $parent = new RelatedWithAttributesModel; + $parent->id = 123; + + $relatedModel = $parent + ->morphMany(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes(['is_admin' => true]) + ->forceCreate(); + + $this->assertSame(123, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertTrue($relatedModel->fresh()->is_admin); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testForceCreateSetsRelationKeysBeforePendingAttributeMutators(bool $polymorphic): void + { + $this->createTable(); + $parent = new RelatedWithAttributesModel; + $parent->id = 123; + $relation = $polymorphic + ? $parent->morphMany(RelatedWithParentAttributesModel::class, 'relatable') + : $parent->hasMany(RelatedWithParentAttributesModel::class, 'parent_id'); + $foreignKey = $polymorphic ? 'relatable_id' : 'parent_id'; + + $model = $relation->withAttributes([ + 'parent_reference' => true, + $foreignKey => 456, + ...($polymorphic ? ['relatable_type' => 'wrong-type'] : []), + ])->forceCreate([$foreignKey => 789]); + + $this->assertSame( + ($polymorphic ? $parent::class : 'parent') . ':123', + $model->fresh()->parent_reference + ); + } + + /** + * Create the table used to verify persisted relationship attributes. + */ + protected function createTable(): void + { + DB::schema()->create((new RelatedWithAttributesModel)->getTable(), function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('parent_id')->nullable(); + $table->nullableMorphs('relatable'); + $table->boolean('is_admin')->default(false); + $table->string('parent_reference')->nullable(); + $table->timestamps(); + }); + } } class RelatedWithAttributesModel extends Model @@ -298,3 +372,18 @@ class RelatedWithAttributesModel extends Model 'is_admin' => 'boolean', ]; } + +class RelatedWithParentAttributesModel extends RelatedWithAttributesModel +{ + protected ?string $table = 'related_with_attributes_models'; + + /** + * Derive the reference from the relationship keys. + */ + public function setParentReferenceAttribute(bool $value): void + { + $type = $this->attributes['relatable_type'] ?? 'parent'; + $key = $this->attributes['relatable_id'] ?? $this->attributes['parent_id'] ?? 'missing'; + $this->attributes['parent_reference'] = "{$type}:{$key}"; + } +} diff --git a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php index c97fdaadec..0f193f2a88 100644 --- a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php +++ b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php @@ -82,6 +82,21 @@ public function testItLoadsAHasOneThroughRelationWithCustomKeys(): void $this->assertSame('A title', $contract->title); } + public function testIsMethod(): void + { + $otherPosition = Position::create(['id' => 1, 'name' => 'Engineer', 'shortname' => 'eng']); + $position = Position::create(['id' => 2, 'name' => 'President', 'shortname' => 'ps']); + $user = $position->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'position_short' => 'ps']); + $otherUser = $otherPosition->user()->create(['id' => 2, 'email' => 'abigailotwell@gmail.com', 'position_short' => 'eng']); + $contract = $user->contract()->create(['title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']); + $otherContract = $otherUser->contract()->create(['title' => 'Another title', 'body' => 'Another body', 'email' => 'abigailotwell@gmail.com']); + + $this->assertTrue($position->contract()->is($contract)); + $this->assertFalse($position->contract()->is($otherContract)); + $this->assertTrue($otherPosition->contract()->isNot($contract)); + $this->assertFalse($otherPosition->contract()->isNot($otherContract)); + } + public function testItLoadsADefaultHasOneThroughRelation(): void { $this->migrateDefault(); diff --git a/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php b/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php index 23ea857e34..ef16c5061d 100755 --- a/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php +++ b/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php @@ -107,6 +107,12 @@ public function testCorrectLatestOfManyQuery(): void $this->assertSame('select "logins".* from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" inner join (select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" where "intermediates"."user_id" = ? group by "intermediates"."user_id") as "latest_login" on "latest_login"."id_aggregate" = "logins"."id" and "latest_login"."user_id" = "intermediates"."user_id" where "intermediates"."user_id" = ?', $relation->getQuery()->toSql()); } + public function testCorrectLatestOfManyExistenceQuery(): void + { + $query = User::has('latest_login'); + $this->assertSame('select * from "users" where exists (select * from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" inner join (select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" group by "intermediates"."user_id") as "latest_login" on "latest_login"."id_aggregate" = "logins"."id" and "latest_login"."user_id" = "intermediates"."user_id" where "users"."id" = "intermediates"."user_id")', $query->toSql()); + } + public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery(): void { $user = User::create(); @@ -333,6 +339,19 @@ public function testIsMethod(): void $this->assertTrue($user->latest_login()->is($login2)); } + public function testIsMethodComparesTheFarParent(): void + { + $otherUser = User::create(); + $user = User::create(); + $login = $user->intermediates()->create()->logins()->create(); + $otherLogin = $otherUser->intermediates()->create()->logins()->create(); + + $this->assertTrue($user->latest_login()->is($login)); + $this->assertFalse($user->latest_login()->is($otherLogin)); + $this->assertTrue($otherUser->latest_login()->is($otherLogin)); + $this->assertFalse($otherUser->latest_login()->is($login)); + } + public function testIsNotMethod(): void { $user = User::factory()->hasIntermediates(2)->create(); diff --git a/tests/Database/DatabaseEloquentWithAttributesTest.php b/tests/Database/DatabaseEloquentWithAttributesTest.php index 9f1e52fc4c..7bf95e6a23 100755 --- a/tests/Database/DatabaseEloquentWithAttributesTest.php +++ b/tests/Database/DatabaseEloquentWithAttributesTest.php @@ -112,6 +112,46 @@ public function testAddsWithCastsViaDb(): void $this->assertSame(WithAttributesEnum::Internal, $model->type); } + public function testForceCreateAddsAttributesViaDb(): void + { + $this->bootTable(); + + WithAttributesModel::query() + ->withAttributes([ + 'is_admin' => 1, + 'type' => WithAttributesEnum::Internal, + ]) + ->forceCreate(['first_name' => 'FIRST', 'last_name' => 'LAST']); + + $model = WithAttributesModel::first(); + + $this->assertTrue($model->is_admin); + $this->assertSame(WithAttributesEnum::Internal, $model->type); + } + + public function testForceCreateAppliesPendingAttributeMutatorsOnce(): void + { + $this->bootTable(); + $stream = fopen('php://memory', 'w+'); + + try { + fwrite($stream, 'first'); + rewind($stream); + + $model = WithAttributesStreamModel::query() + ->withAttributes(['first_name' => $stream]) + ->forceCreate([ + 'last_name' => 'last', + 'is_admin' => false, + 'type' => WithAttributesEnum::Internal, + ]); + + $this->assertSame('first', $model->fresh()->getRawOriginal('first_name')); + } finally { + fclose($stream); + } + } + protected function bootTable(): void { $this->schema()->create((new WithAttributesModel)->getTable(), function ($table) { @@ -158,6 +198,19 @@ protected function lastName(): Attribute } } +class WithAttributesStreamModel extends WithAttributesModel +{ + protected ?string $table = 'with_attributes_models'; + + /** + * Read the pending name from its stream. + */ + public function setFirstNameAttribute(mixed $value): void + { + $this->attributes['first_name'] = stream_get_contents($value); + } +} + enum WithAttributesEnum: string { case Internal = 'int'; diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index d8026af0be..3e588ba9c0 100644 --- a/tests/Database/DatabaseManagerTest.php +++ b/tests/Database/DatabaseManagerTest.php @@ -5,8 +5,13 @@ namespace Hypervel\Tests\Database; use Closure; +use Hypervel\Config\Repository; +use Hypervel\Container\Container; +use Hypervel\Context\CoroutineContext; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionResolver; +use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\DatabaseManager; use Hypervel\Database\Events\ConnectionEstablished; use Hypervel\Database\PdoConnection; @@ -39,6 +44,42 @@ protected function setUp(): void ]); } + public function testSetDefaultConnectionAcceptsEnums(): void + { + $container = new Container; + $config = new Repository(['database' => ['default' => 'sqlite']]); + $container->instance('config', $config); + + $manager = new DatabaseManager($container, m::mock(ConnectionFactory::class)); + + $manager->setDefaultConnection(DatabaseManagerTestBackedConnection::Reporting); + + $this->assertSame('reporting', $manager->getDefaultConnection()); + $this->assertSame('reporting', CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY)); + $this->assertSame('sqlite', $config->get('database.default')); + + $manager->setDefaultConnection(DatabaseManagerTestUnitConnection::analytics); + + $this->assertSame('analytics', $manager->getDefaultConnection()); + + $manager->setDefaultConnection(DatabaseManagerConnectionName::Zero); + + $this->assertSame('0', $manager->getDefaultConnection()); + } + + public function testUsingConnectionAcceptsEnumsAndRestoresTheDefaultConnection(): void + { + $container = new Container; + $container->instance('config', new Repository(['database' => ['default' => 'sqlite']])); + + $manager = new DatabaseManager($container, m::mock(ConnectionFactory::class)); + + $default = $manager->usingConnection(DatabaseManagerTestBackedConnection::Reporting, fn (): ?string => $manager->getDefaultConnection()); + + $this->assertSame('reporting', $default); + $this->assertSame('sqlite', $manager->getDefaultConnection()); + } + public function testReconnectorAcceptsArrayCallable(): void { $manager = $this->db->getDatabaseManager(); @@ -689,6 +730,16 @@ protected function createSqliteUsersDatabase(string $path, string $name): void } } +enum DatabaseManagerTestBackedConnection: string +{ + case Reporting = 'reporting'; +} + +enum DatabaseManagerTestUnitConnection +{ + case analytics; +} + enum DatabaseManagerConnectionName: int { case Zero = 0; diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php index cc20129d49..cab6c59c89 100644 --- a/tests/Database/DatabaseQueryBuilderPaginationTest.php +++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php @@ -4,12 +4,14 @@ namespace Hypervel\Tests\Database; +use Hypervel\Database\BinaryParameter; use Hypervel\Database\Connection; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Grammars\Grammar; use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\SQLiteConnection; +use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use Mockery as m; use PDO; @@ -18,6 +20,30 @@ class DatabaseQueryBuilderPaginationTest extends TestCase { + public function testBinaryStringsCanBePagedUsingAnExplicitCursorAlias(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table files (hash blob primary key)'); + $hashes = [pack('N', 1), pack('N', 2), pack('N', 3)]; + + foreach ($hashes as $hash) { + $connection->table('files')->insert(['hash' => new BinaryParameter($hash)]); + } + + $actual = []; + $connection->table('files')->afterQuery(function (Collection $rows): void { + foreach ($rows as $row) { + $row->cursor = new BinaryParameter($row->hash); + } + })->chunkById(2, function (Collection $rows) use (&$actual): bool { + array_push($actual, ...$rows->pluck('hash')->all()); + + return count($actual) < 4; + }, 'hash', 'cursor'); + + $this->assertSame($hashes, $actual); + } + #[DataProvider('readWriteRouting')] public function testGroupedCountsPreserveReadWriteRouting(bool $useWritePdo, int $expectedCount): void { diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 92ba69b64f..3191023a61 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -321,6 +321,38 @@ public function testCanBuildOnDemandDisk(): void ])); } + public function testCanBuildLocalDisksThatLazilyCreateTheirRootDirectory(): void + { + $filesystem = new FilesystemManager($this->getContainer()); + $root = $this->tempDir . '/lazy-root'; + + $disk = $filesystem->build([ + 'driver' => 'local', + 'root' => $root, + 'lazy_root_creation' => true, + ]); + + $this->assertDirectoryDoesNotExist($root); + + $disk->put('filename.txt', 'file content'); + + $this->assertDirectoryExists($root); + $this->assertSame('file content', $disk->get('filename.txt')); + } + + public function testCanBuildLocalDisksThatEagerlyCreateTheirRootDirectory(): void + { + $filesystem = new FilesystemManager($this->getContainer()); + $root = $this->tempDir . '/eager-root'; + + $filesystem->build([ + 'driver' => 'local', + 'root' => $root, + ]); + + $this->assertDirectoryExists($root); + } + public function testConfiguredOndemandDiskNameIsReserved(): void { $filesystem = new FilesystemManager($this->getContainer([ diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index c8673b6df0..3a145dcd36 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -4472,6 +4472,36 @@ public function testTransferStatsArePresentWhenFakingTheRequestUsingAPromiseResp $this->assertSame('https://example.com', (string) $effectiveUri); } + #[TestWith([false])] + #[TestWith([true])] + public function testPendingRequestIsReleasedWithoutGarbageCollectionAfterThrow(bool $withoutFactory): void + { + $body = str_repeat('x', 1024); + $this->factory->fake(['https://example.com' => $this->factory::response($body)]); + $garbageCollectionEnabled = gc_enabled(); + gc_disable(); + + try { + $pendingRequest = $withoutFactory + ? (new PendingRequest) + ->throwUnless(static fn (Response $response): bool => false) + ->stub(static fn (): PromiseInterface => Factory::response($body)) + : $this->factory->throw(); + $response = $pendingRequest->get('https://example.com'); + + $this->assertSame($body, $response->body()); + + $reference = WeakReference::create($pendingRequest); + unset($pendingRequest); + + $this->assertNull($reference->get()); + } finally { + if ($garbageCollectionEnabled) { + gc_enable(); + } + } + } + public function testTransferStatsArePresentWhenFakingAnAsyncRequestUsingAPromiseResponse(): void { $this->factory->fake(['https://example.com' => $this->factory::response()]); @@ -6012,31 +6042,6 @@ public function testTooManyRedirectsWithFakedRedirectChain(): void $this->factory->maxRedirects(1)->get('https://1.example.com'); } - public function testPendingRequestsAreFreedOnceUnset(): void - { - $garbageCollectionEnabled = gc_enabled(); - gc_disable(); - - try { - $request = (new PendingRequest) - ->throwUnless(static fn (Response $response): bool => false) - ->stub(static fn (): PromiseInterface => Factory::response('ok')); - - $reference = WeakReference::create($request); - $response = $request->post('http://localhost/memory-test'); - - $this->assertSame('ok', $response->body()); - - unset($request, $response); - - $this->assertNull($reference->get()); - } finally { - if ($garbageCollectionEnabled) { - gc_enable(); - } - } - } - public function testRequestExceptionIsNotThrownIfThePendingRequestIsSetToThrowOnFailureButTheResponseIsSuccessful(): void { $this->factory->fake([ diff --git a/tests/HttpServer/ServerTest.php b/tests/HttpServer/ServerTest.php index 02853467cd..254bdbb47b 100644 --- a/tests/HttpServer/ServerTest.php +++ b/tests/HttpServer/ServerTest.php @@ -25,6 +25,7 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -571,11 +572,17 @@ public function testOnRequestDispatchesLifecycleEventsWhenListenersAreRegistered ], $order); } - public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellation(bool $chained): void { CoordinatorManager::until(Constants::WORKER_START)->resume(); $cancellation = new CanceledException; + if ($chained) { + $cancellation = new RuntimeException('cleanup failed', previous: $cancellation); + } + $kernel = m::mock(KernelContract::class); $kernel->shouldReceive('handle')->once()->andThrow($cancellation); $kernel->shouldNotReceive('terminate'); @@ -600,21 +607,17 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio $swooleResponse->shouldReceive('status', 'header', 'cookie', 'rawcookie', 'write', 'sendfile', 'end')->never(); $caughtCancellation = null; - $failure = null; $completed = new Channel(1); $requestCoroutine = EngineCoroutine::create(function () use ( $completed, $server, $swooleResponse, &$caughtCancellation, - &$failure, ): void { try { $server->onRequest($this->createSwooleRequest(), $swooleResponse); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $caughtCancellation = $exception; - } catch (Throwable $throwable) { - $failure = $throwable; } finally { $completed->push(true); } @@ -622,7 +625,6 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio try { $this->assertTrue($completed->pop(1)); - $this->assertNull($failure); $this->assertSame($cancellation, $caughtCancellation); $this->assertSame([], $dispatchedEvents); $this->assertFalse(RequestContext::has()); @@ -637,9 +639,15 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio public function testOnRequestStopsAtCancellationDuringFinalization( string $stage, array $expectedOrder, + bool $chained, ): void { CoordinatorManager::until(Constants::WORKER_START)->resume(); $cancellation = new CanceledException; + + if ($chained) { + $cancellation = new RuntimeException('cleanup failed', previous: $cancellation); + } + $order = []; $kernel = m::mock(KernelContract::class); @@ -700,23 +708,29 @@ public function testOnRequestStopsAtCancellationDuringFinalization( }); } + $caught = null; + try { $server->onRequest($this->createSwooleRequest(), $swooleResponse); - $this->fail('Expected cancellation to propagate.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + $this->assertSame($cancellation, $caught); $this->assertSame($expectedOrder, $order); } public static function lifecycleCancellationStages(): array { return [ - 'handled listener' => ['handled', ['handled']], - 'response send' => ['send', ['handled']], - 'sent listener' => ['sent', ['handled', 'sent']], - 'termination' => ['terminate', ['handled', 'sent', 'terminate']], + 'handled listener' => ['handled', ['handled'], false], + 'response send' => ['send', ['handled'], false], + 'sent listener' => ['sent', ['handled', 'sent'], false], + 'termination' => ['terminate', ['handled', 'sent', 'terminate'], false], + 'handled listener cleanup' => ['handled', ['handled'], true], + 'response send cleanup' => ['send', ['handled'], true], + 'sent listener cleanup' => ['sent', ['handled', 'sent'], true], + 'termination cleanup' => ['terminate', ['handled', 'sent', 'terminate'], true], ]; } diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php index 64b2cb1b27..c983d4a887 100644 --- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php +++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php @@ -6,16 +6,19 @@ use Hypervel\Database\BinaryParameter; use Hypervel\Database\Eloquent\Casts\AsBinary; +use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Pagination\Cursor; use Hypervel\Support\Facades\Schema; use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; @@ -206,6 +209,114 @@ public function testFactoryInsertPreparesGeneratedAndSuppliedBinaryPrimaryKeys() } } + #[TestWith(['chunkById', false])] + #[TestWith(['chunkByIdDesc', true])] + #[TestWith(['lazyById', false])] + #[TestWith(['lazyByIdDesc', true])] + public function testBinaryPrimaryKeysCanBePaged(string $method, bool $descending): void + { + $ids = [ + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000003', + ]; + + foreach ($ids as $id) { + AsBinaryPrimaryKeyModel::create(['id' => $id, 'name' => $id]); + } + + $query = AsBinaryPrimaryKeyModel::query(); + $actual = []; + + if (str_starts_with($method, 'lazy')) { + $actual = $query->{$method}(2)->take(4)->pluck('id')->all(); + } else { + $query->{$method}(2, function (Collection $models) use (&$actual): bool { + array_push($actual, ...$models->pluck('id')->all()); + + return count($actual) < 4; + }); + } + + $this->assertSame($descending ? array_reverse($ids) : $ids, $actual); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testBinaryPrimaryKeysCanBeCursorPaginatedInBothDirections(bool $aliased): void + { + $ids = [ + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000003', + ]; + + foreach ($ids as $id) { + AsBinaryPrimaryKeyModel::create(['id' => $id, 'name' => $id]); + } + + if (! $aliased) { + $bytes = Uuid::fromString($ids[1])->toBinary(); + $stream = fopen('php://memory', 'w+'); + + try { + fwrite($stream, $bytes); + rewind($stream); + + foreach ([new BinaryParameter($bytes), $stream] as $parameter) { + $explicit = AsBinaryPrimaryKeyModel::query()->orderBy('id') + ->cursorPaginate(2, cursor: new Cursor(['id' => $parameter])); + + $this->assertSame([$ids[2]], $explicit->pluck('id')->all()); + } + } finally { + fclose($stream); + } + } + + $query = fn () => $aliased + ? AsBinaryPrimaryKeyModel::query()->from('binary_primary_keys as identifiers')->orderBy('identifiers.id') + : AsBinaryPrimaryKeyModel::query(); + $first = $query()->cursorPaginate(2); + $next = $query()->cursorPaginate(2, cursor: $first->nextCursor()->encode()); + + $this->assertSame([$ids[2]], $next->pluck('id')->all()); + + $previous = $query()->cursorPaginate(2, cursor: $next->previousCursor()->encode()); + + $this->assertSame([$ids[0], $ids[1]], $previous->pluck('id')->all()); + } + + public function testCompoundCursorPaginationPreservesBinaryEqualityAcrossUnions(): void + { + foreach ([1, 2, 3] as $id) { + AsBinaryIdentifierModel::create([ + 'uuid' => '00000000-0000-0000-0000-00000000000' . $id, + 'ulid' => $id < 3 ? '00000000000000000000000001' : '00000000000000000000000002', + ]); + } + + $actual = []; + $cursor = null; + + for ($page = 0; $page < 4; ++$page) { + $query = AsBinaryIdentifierModel::query()->where('id', 1) + ->unionAll(AsBinaryIdentifierModel::query()->where('id', 2)) + ->unionAll(AsBinaryIdentifierModel::query()->where('id', 3)) + ->orderBy('ulid') + ->orderBy('id'); + $results = $query->cursorPaginate(1, cursor: $cursor); + array_push($actual, ...$results->pluck('id')->all()); + $cursor = $results->nextCursor()?->encode(); + + if ($cursor === null) { + break; + } + } + + $this->assertSame([1, 2, 3], $actual); + } + public function testBinaryPrimaryKeysPreserveBindingIntentAcrossModelOperations(): void { $primaryId = '21107c1e-6448-43c2-b80b-40491d165946'; diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index 77a91d53f8..b884195aaf 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\Integration\Database; +use Hypervel\Database\BinaryParameter; +use Hypervel\Database\Eloquent\Casts\AsBinary; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\HasManyThrough; +use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; use Hypervel\Pagination\Cursor; @@ -338,6 +341,32 @@ public function testCursorPaginationPreservesFloatOrderValues(): void $this->assertSame(2.5, $result->items()[0]->score); } + public function testCursorPaginationUsesTheBinaryPivotCast(): void + { + Schema::table('test_post_user', function (Blueprint $table): void { + $table->binary('position', length: 16, fixed: true)->nullable(); + }); + $user = TestUser::create(); + + foreach ([1, 2, 3] as $position) { + $post = TestPost::create(); + DB::table('test_post_user')->insert([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'position' => new BinaryParameter(hex2bin(str_pad((string) $position, 32, '0', STR_PAD_LEFT))), + ]); + } + + $relation = fn (): BelongsToMany => $user->favoritePosts() + ->using(CursorBinaryPivot::class) + ->withPivot('position') + ->orderBy('test_post_user.position'); + $first = $relation()->cursorPaginate(2); + $next = $relation()->cursorPaginate(2, cursor: $first->nextCursor()->encode()); + + $this->assertSame([3], $next->pluck('id')->all()); + } + public function testRelationsAcceptCursorObjects(): void { TestOwner::fillAndInsert([[]]); @@ -390,6 +419,11 @@ public function favoritePosts(): BelongsToMany } } +class CursorBinaryPivot extends Pivot +{ + protected array $casts = ['position' => AsBinary::class . ':uuid']; +} + class TestOwner extends Model { protected array $guarded = []; diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index d0792a196b..bf452d1b6f 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -154,6 +154,22 @@ public function testHasSameParentAndThroughParentTable(): void $this->assertEquals([1], $categories->pluck('id')->all()); } + public function testHasOneOfManySameParentAndThroughParentTable(): void + { + Category::create(); + Category::create(); + Category::create(['parent_id' => 1]); + Category::create(['parent_id' => 2]); + + $oldest = Product::create(['category_id' => 3]); + $latest = Product::create(['category_id' => 3]); + + $this->assertEquals([1], Category::has('latestSubProduct')->pluck('id')->all()); + $this->assertEquals([1], Category::whereHas('latestSubProduct', fn ($query) => $query->whereKey($latest->id))->pluck('id')->all()); + $this->assertEquals([], Category::whereHas('latestSubProduct', fn ($query) => $query->whereKey($oldest->id))->pluck('id')->all()); + $this->assertEquals([1 => 1, 2 => 0, 3 => 0, 4 => 0], Category::withCount('latestSubProduct')->pluck('latest_sub_product_count', 'id')->all()); + } + public function testFirstOrNewOnMissingRecord(): void { $taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']); @@ -599,6 +615,14 @@ public function subProducts(): HasManyThrough { return $this->hasManyThrough(Product::class, self::class, 'parent_id'); } + + /** + * Get the latest product in the category's child categories. + */ + public function latestSubProduct(): HasOneThrough + { + return $this->hasOneThrough(Product::class, self::class, 'parent_id')->latestOfMany(); + } } class Product extends Model diff --git a/tests/Integration/Database/EloquentMorphOneIsTest.php b/tests/Integration/Database/EloquentMorphOneIsTest.php index 37997b51af..7d344ae0bc 100644 --- a/tests/Integration/Database/EloquentMorphOneIsTest.php +++ b/tests/Integration/Database/EloquentMorphOneIsTest.php @@ -82,6 +82,16 @@ public function testChildIsNotModelWithAnotherTable(): void $this->assertTrue($parent->attachment()->isNot($child)); } + public function testChildIsNotModelOfAnotherParentType(): void + { + $parent = Post::first(); + $child = Attachment::first(); + $child->attachable_type = 'another-type'; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + public function testChildIsNotModelWithAnotherConnection(): void { $parent = Post::first(); diff --git a/tests/Integration/Database/EloquentPivotEventsTest.php b/tests/Integration/Database/EloquentPivotEventsTest.php index 8e4c6d5f25..9c96931542 100644 --- a/tests/Integration/Database/EloquentPivotEventsTest.php +++ b/tests/Integration/Database/EloquentPivotEventsTest.php @@ -12,6 +12,7 @@ use Hypervel\Database\Eloquent\Relations\MorphToMany; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Integration\Database\DatabaseTestCase; @@ -61,6 +62,11 @@ protected function afterRefreshingDatabase(): void $table->foreignId('equipment_id'); }); + Schema::create('equipmentables_no_id', function (Blueprint $table): void { + $table->morphs('equipmentable'); + $table->foreignId('equipment_id'); + }); + Schema::create('project_users', function (Blueprint $table) { $table->integer('user_id'); $table->integer('project_id'); @@ -207,6 +213,23 @@ public function testCustomMorphPivotClassDetachAttributes(): void PivotEventsTestModelEquipment::$eventsMorphTypes ); } + + public function testCustomMorphPivotWithoutPrimaryKeyTouchesOwnersOnDetach(): void + { + CarbonImmutable::setTestNow('2017-10-10 10:10:10'); + + $project = PivotEventsTestProject::forceCreate(['name' => 'Test Project']); + $equipment = PivotEventsTestEquipment::forceCreate(['name' => 'important-equipment']); + + $project->touchingEquipments()->attach($equipment); + + CarbonImmutable::setTestNow('2017-10-10 11:10:10'); + + $project->touchingEquipments()->detach($equipment); + + $this->assertSame(0, $project->touchingEquipments()->count()); + $this->assertSame('2017-10-10 11:10:10', $equipment->refresh()->updated_at->toDateTimeString()); + } } class PivotEventsTestUser extends Model @@ -261,6 +284,15 @@ public function equipments(): MorphToMany { return $this->morphToMany(PivotEventsTestEquipment::class, 'equipmentable')->using(PivotEventsTestModelEquipment::class); } + + /** + * Get equipment through a pivot that touches its owner. + */ + public function touchingEquipments(): MorphToMany + { + return $this->morphToMany(PivotEventsTestEquipment::class, 'equipmentable', 'equipmentables_no_id') + ->using(PivotEventsTestTouchingModelEquipment::class); + } } class PivotEventsTestModelEquipment extends MorphPivot @@ -327,6 +359,21 @@ public function equipmentable(): MorphTo } } +class PivotEventsTestTouchingModelEquipment extends MorphPivot +{ + public ?string $table = 'equipmentables_no_id'; + + protected array $touches = ['equipment']; + + /** + * Get the equipment attached by this pivot. + */ + public function equipment(): BelongsTo + { + return $this->belongsTo(PivotEventsTestEquipment::class); + } +} + class PivotEventsTestCollaborator extends Pivot { public ?string $table = 'project_users'; diff --git a/tests/Integration/Database/Postgres/BinaryCursorTest.php b/tests/Integration/Database/Postgres/BinaryCursorTest.php new file mode 100644 index 0000000000..9bce6b2a6c --- /dev/null +++ b/tests/Integration/Database/Postgres/BinaryCursorTest.php @@ -0,0 +1,46 @@ +binary('hash')->primary(); + }); + + foreach ([1, 2, 3] as $id) { + DB::table('binary_cursors')->insert(['hash' => new BinaryParameter(pack('N', $id))]); + } + } + + #[TestWith(['chunkById', [1, 2, 3]])] + #[TestWith(['chunkByIdDesc', [3, 2, 1]])] + public function testConsumedBinaryStreamsCanBeUsedAsPagingCursors(string $method, array $expected): void + { + $actual = []; + + DB::table('binary_cursors')->{$method}(2, function (Collection $rows) use (&$actual): bool { + foreach ($rows as $row) { + $actual[] = unpack('N', stream_get_contents($row->hash))[1]; + } + + return count($actual) < 4; + }, 'hash'); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Integration/Http/MarkdownResponseTest.php b/tests/Integration/Http/MarkdownResponseTest.php new file mode 100644 index 0000000000..9fc3efcf12 --- /dev/null +++ b/tests/Integration/Http/MarkdownResponseTest.php @@ -0,0 +1,60 @@ +markdown("# Hello\n\nWorld"); + }); + + $response = $this->get('/markdown'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/markdown; charset=utf-8'); + $response->assertContent("# Hello\n\nWorld"); + } + + public function testMarkdownResponseWithStatusAndHeaders(): void + { + Route::get('/markdown', function (): Response { + return response()->markdown('# Not Found', 404, [ + 'Content-Type' => 'text/html', + 'X-Foo' => 'bar', + ]); + }); + + $response = $this->get('/markdown'); + + $response->assertNotFound(); + $response->assertHeader('Content-Type', 'text/markdown; charset=utf-8'); + $response->assertHeader('X-Foo', 'bar'); + $response->assertContent('# Not Found'); + } + + public function testMarkdownResponseCanBeNegotiated(): void + { + Route::get('/markdown', function (Request $request): Response { + return $request->wantsMarkdown() + ? response()->markdown('# Hello') + : response('

Hello

'); + }); + + $this->get('/markdown', ['Accept' => 'text/markdown']) + ->assertHeader('Content-Type', 'text/markdown; charset=utf-8') + ->assertContent('# Hello'); + + $this->get('/markdown', ['Accept' => 'text/html']) + ->assertHeader('Content-Type', 'text/html; charset=utf-8') + ->assertContent('

Hello

'); + } +} diff --git a/tests/Integration/Queue/DebouncedJobTest.php b/tests/Integration/Queue/DebouncedJobTest.php index 332cebcece..9bd5659268 100644 --- a/tests/Integration/Queue/DebouncedJobTest.php +++ b/tests/Integration/Queue/DebouncedJobTest.php @@ -203,6 +203,23 @@ public function testQueueFakeCapturesDebouncedJob(): void Queue::assertPushed(DebouncedTestJob::class); } + public function testScopedQueueFakeReleasesDebounceState(): void + { + $job = new DebouncedWithMaxWaitJob('entity-1'); + $cache = $this->app->get(Cache::class); + $key = DebounceLock::getKey($job); + + Queue::fakeFor(function () use ($job, $cache, $key): void { + dispatch($job); + + $this->assertTrue($cache->has($key)); + $this->assertTrue($cache->has($key . ':first_dispatched_at')); + }); + + $this->assertNull($cache->get($key)); + $this->assertNull($cache->get($key . ':first_dispatched_at')); + } + public function testBusFakeRetainsDebounceMaximumWaitState(): void { Bus::fake(); diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 7529f6a152..d730bb70d8 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -4,7 +4,9 @@ namespace Hypervel\Tests\Integration\Queue\UniqueJobTest; +use Closure; use Exception; +use Hypervel\Bus\DispatchLockContext; use Hypervel\Bus\Queueable; use Hypervel\Bus\UniqueLock; use Hypervel\Cache\Repository as CacheRepository; @@ -20,6 +22,7 @@ use Hypervel\Foundation\Bus\Dispatchable; use Hypervel\Queue\Events\UniqueJobSkipped; use Hypervel\Queue\InteractsWithQueue; +use Hypervel\Queue\QueueManager; use Hypervel\Queue\SerializesModels; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\DB; @@ -28,7 +31,10 @@ use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Testbench\Factories\UserFactory; use Hypervel\Tests\Integration\Queue\QueueTestCase; +use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; #[WithMigration] #[WithMigration('cache')] @@ -317,6 +323,117 @@ public function testQueueFakeReleasesUniqueJobLocksBetweenFakes(): void Queue::assertPushed(UniqueTestJob::class); } + public function testQueueFakeForReleasesUniqueJobLocks(): void + { + Queue::fakeFor(function (): void { + UniqueTestJob::dispatch(); + Queue::assertPushed(UniqueTestJob::class); + }); + + $this->assertTrue( + $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get() + ); + } + + public function testQueueFakeReleasesUniqueListenerLocksBetweenFakes(): void + { + Event::listen(UniqueTestEvent::class, UniqueTestListener::class); + + Queue::fake(); + + event(new UniqueTestEvent); + $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class)); + + Queue::fake(); + + event(new UniqueTestEvent); + $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class)); + } + + public function testQueueFakeForReleasesUniqueListenerLocks(): void + { + Event::listen(UniqueTestEvent::class, UniqueTestListener::class); + + Queue::fakeFor(function (): void { + event(new UniqueTestEvent); + $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class)); + }); + + Queue::fake(); + + event(new UniqueTestEvent); + $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class)); + } + + public function testQueueFakeExceptForReleasesUniqueJobLocks(): void + { + Queue::fakeExceptFor(function (): void { + UniqueTestJob::dispatch(); + Queue::assertPushed(UniqueTestJob::class); + }); + + $this->assertTrue( + $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get() + ); + } + + #[TestWith(['fakeFor'])] + #[TestWith(['fakeExceptFor'])] + public function testScopedQueueFakeReleasesReplacementFakesLocks(string $method): void + { + Queue::{$method}(function (): void { + Queue::fake(); + UniqueTestJob::dispatch(); + Queue::assertPushed(UniqueTestJob::class); + }); + + $this->assertTrue( + $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get() + ); + } + + #[TestWith(['fakeFor'])] + #[TestWith(['fakeExceptFor'])] + public function testScopedQueueFakeReleasesItsLocksAfterSwappingTheFacade(string $method): void + { + $manager = Queue::getFacadeRoot(); + + Queue::{$method}(function (): void { + UniqueTestJob::dispatch(); + Queue::assertPushed(UniqueTestJob::class); + + Queue::swap(m::mock(QueueManager::class)); + }); + + $this->assertSame($manager, Queue::getFacadeRoot()); + $this->assertTrue( + $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get() + ); + } + + #[TestWith(['fakeFor'])] + #[TestWith(['fakeExceptFor'])] + public function testScopedQueueFakeRestoresManagerWhenLockCleanupThrows(string $method): void + { + $manager = Queue::getFacadeRoot(); + $failure = new RuntimeException('Cache unavailable during lock cleanup.'); + $caught = null; + DispatchLockContext::captureReleaseContextUsing( + static fn (): Closure => static fn (): never => throw $failure + ); + + try { + Queue::{$method}(function (): void { + UniqueTestJob::dispatch(); + }); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertSame($manager, Queue::getFacadeRoot()); + } + public function testQueueFakePreservesUniqueJobLockWithinTest(): void { Queue::fake(); @@ -598,6 +715,28 @@ public function handle(): void } } +class UniqueTestEvent +{ +} + +class UniqueTestListener implements ShouldQueue, ShouldBeUnique +{ + /** + * Get the listener's unique ID. + */ + public function uniqueId(): string + { + return 'unique-id-1'; + } + + /** + * Handle the event. + */ + public function handle(UniqueTestEvent $event): void + { + } +} + class NestedUniqueParentJob implements ShouldQueue, ShouldBeUnique { use Dispatchable; diff --git a/tests/ObjectPool/LeaseTest.php b/tests/ObjectPool/LeaseTest.php index dbb4d13291..d313560cd5 100644 --- a/tests/ObjectPool/LeaseTest.php +++ b/tests/ObjectPool/LeaseTest.php @@ -139,11 +139,24 @@ public function testDiscardFailureDoesNotMaskAReleaseCallbackFailure(): void public function testReleaseCallbackPreservesCancellationPrecedence( bool $callbackIsCancellation, bool $expectCallbackFailure, + bool $callbackCleanupThrows = false, + bool $discardCleanupThrows = false, ): void { $callbackFailure = $callbackIsCancellation ? new CanceledException('reset canceled') : new RuntimeException('reset failed'); $discardCancellation = new CanceledException('discard canceled'); + if ($callbackCleanupThrows) { + $callbackFailure = new RuntimeException('reset cleanup failed', previous: $callbackFailure); + } + if ($discardCleanupThrows) { + $discardCancellation = new RuntimeException('discard cleanup failed', previous: $discardCancellation); + } + $handler = m::mock(ExceptionHandler::class); + $expectation = $handler->shouldReceive('report')->with($discardCancellation); + $expectCallbackFailure && $discardCleanupThrows ? $expectation->once() : $expectation->never(); + $this->container()->instance(ExceptionHandler::class, $handler); + $pool = new ContractOnlyObjectPool; $pool->discardException = $discardCancellation; $lease = new Lease($pool, new stdClass, function () use ($callbackFailure): never { @@ -153,7 +166,7 @@ public function testReleaseCallbackPreservesCancellationPrecedence( try { $lease->release(); $this->fail('Expected lease release to fail.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame( $expectCallbackFailure ? $callbackFailure : $discardCancellation, $exception, @@ -166,6 +179,9 @@ public static function releaseCallbackCancellationProvider(): array return [ 'ordinary callback failure' => [false, false], 'canceled callback' => [true, true], + 'chained callback cancellation' => [true, true, true], + 'chained discard cancellation' => [false, false, false, true], + 'both cancellations chained' => [true, true, true, true], ]; } @@ -240,6 +256,8 @@ public function testFailureFinalizersPreserveCancellationPrecedence( bool $primaryIsCancellation, bool $cleanupIsCancellation, bool $expectPrimary, + bool $primaryCleanupThrows = false, + bool $cleanupThrowsAgain = false, ): void { $container = $this->container(); $primary = $primaryIsCancellation @@ -248,9 +266,15 @@ public function testFailureFinalizersPreserveCancellationPrecedence( $cleanup = $cleanupIsCancellation ? new CanceledException('cleanup canceled') : new RuntimeException('cleanup failed'); + if ($primaryCleanupThrows) { + $primary = new RuntimeException('operation cleanup failed', previous: $primary); + } + if ($cleanupThrowsAgain) { + $cleanup = new RuntimeException('finalizer cleanup failed', previous: $cleanup); + } $handler = m::mock(ExceptionHandler::class); $expectation = $handler->shouldReceive('report')->with($cleanup); - $cleanupIsCancellation ? $expectation->never() : $expectation->once(); + $expectPrimary && ! $cleanup instanceof CanceledException ? $expectation->once() : $expectation->never(); $container->instance(ExceptionHandler::class, $handler); $pool = new ContractOnlyObjectPool; @@ -275,6 +299,9 @@ public static function failureFinalizerProvider(): array $cases["{$method}: canceled primary and ordinary cleanup"] = [$method, true, false, true]; $cases["{$method}: ordinary primary and canceled cleanup"] = [$method, false, true, false]; $cases["{$method}: canceled primary and cleanup"] = [$method, true, true, true]; + $cases["{$method}: chained primary and native cleanup cancellation"] = [$method, true, true, true, true]; + $cases["{$method}: ordinary primary and chained cleanup cancellation"] = [$method, false, true, false, false, true]; + $cases["{$method}: both cancellations chained"] = [$method, true, true, true, true, true]; } return $cases; diff --git a/tests/ObjectPool/ObjectPoolTest.php b/tests/ObjectPool/ObjectPoolTest.php index ec7f90f89c..bb2b6e910f 100644 --- a/tests/ObjectPool/ObjectPoolTest.php +++ b/tests/ObjectPool/ObjectPoolTest.php @@ -12,11 +12,13 @@ use Hypervel\Engine\Channel; use Hypervel\ObjectPool\Exceptions\PoolClosedException; use Hypervel\ObjectPool\Exceptions\PoolExhaustedException; +use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\ObjectPool; use Hypervel\ObjectPool\PoolOptions; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; @@ -50,10 +52,23 @@ public function testCloseDrainsIdleObjectsAndIsIdempotent(): void $this->assertEqualsCanonicalizing($objects, $destroyed); } - public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellation(): void + #[TestWith([false, false])] + #[TestWith([true, false])] + #[TestWith([false, true])] + #[TestWith([true, true])] + public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellation(bool $firstCleanupThrows, bool $secondCleanupThrows): void { $firstCancellation = new CanceledException('first cleanup canceled'); $secondCancellation = new CanceledException('second cleanup canceled'); + if ($firstCleanupThrows) { + $firstCancellation = new RuntimeException('First cleanup failed.', previous: $firstCancellation); + } + if ($secondCleanupThrows) { + $secondCancellation = new RuntimeException('Second cleanup failed.', previous: $secondCancellation); + } + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->with($secondCancellation)->times($secondCleanupThrows ? 1 : 0); + $this->container()->instance(ExceptionHandler::class, $handler); $destroyed = []; $pool = $this->pool( ['max_objects' => 2], @@ -72,7 +87,7 @@ public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellati try { $pool->close(); $this->fail('Expected idle cleanup cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($firstCancellation, $exception); } @@ -752,9 +767,19 @@ function () use ($failure): never { $this->assertSame(0, $pool->getIdleCount()); } - public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(): void + #[TestWith([false, false])] + #[TestWith([true, false])] + #[TestWith([false, true])] + #[TestWith([true, true])] + public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(bool $cleanupThrows, bool $releaseFails): void { $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Destroy cleanup failed.', previous: $cancellation); + } + $handler = m::mock(ExceptionHandler::class); + $handler->shouldNotReceive('report'); + $this->container()->instance(ExceptionHandler::class, $handler); $pool = $this->pool( destroyCallback: static function () use ($cancellation): never { throw $cancellation; @@ -763,9 +788,13 @@ public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(): void $object = $pool->borrow(); try { - $pool->discard($object); + if ($releaseFails) { + (new Lease($pool, $object, static fn () => throw new RuntimeException('Reset failed.')))->release(); + } else { + $pool->discard($object); + } $this->fail('Expected object destruction cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php b/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php index ea569ab7bc..25d4e89b63 100644 --- a/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php +++ b/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php @@ -17,6 +17,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class DatabaseOutboxPublicationTest extends TestCase { @@ -58,6 +62,30 @@ public function testARejectedLaterChunkRollsBackEveryInsertedMessage(): void $this->assertSame(0, $database->transactionLevel()); } + #[TestWith([false])] + #[TestWith([true])] + public function testCancellationIsNotReturnedAsAPublicationFailure(bool $cleanupThrows): void + { + [$queue, $database] = $this->queue(); + $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Insert cleanup failed.', previous: $cancellation); + } + $database->beforeExecuting(static fn () => throw $cancellation); + + try { + $queue->publishOutboxMessages([$this->message(1, 'orders')]); + $this->fail('Expected publication cancellation to propagate.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } finally { + $database->clearBeforeExecutingCallbacks(); + } + + $this->assertSame(0, $database->table('jobs')->count()); + $this->assertSame(0, $database->transactionLevel()); + } + /** * Create a real target jobs table with a two-row binding limit. */ diff --git a/tests/Queue/Outbox/OutboxDispatchTest.php b/tests/Queue/Outbox/OutboxDispatchTest.php index 402eade6a0..52e2d1d9a7 100644 --- a/tests/Queue/Outbox/OutboxDispatchTest.php +++ b/tests/Queue/Outbox/OutboxDispatchTest.php @@ -32,7 +32,10 @@ use Pheanstalk\Contract\PheanstalkPublisherInterface; use Pheanstalk\Contract\PheanstalkSubscriberInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; use UnitEnum; class OutboxDispatchTest extends TestCase @@ -203,6 +206,31 @@ public function testBulkPreparationFailureStoresNothingAndTerminatesEveryMember( $this->assertSame(0, DB::table('job_outbox')->count()); } + #[TestWith([false])] + #[TestWith([true])] + public function testStagingCancellationDoesNotNotifyFailureOrStoreJobs(bool $bulk): void + { + $queue = $this->queue(); + $cancellation = new RuntimeException('Finalization cleanup failed.', previous: new CanceledException); + $events = $this->app->make('events'); + $events->listen(JobPayloadFinalizing::class, static fn () => throw $cancellation); + $failed = []; + $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void { + $failed[] = $event; + }); + $job = (new OutboxDispatchJob)->viaOutbox(); + + try { + $bulk ? $queue->bulk([$job, clone $job]) : $queue->push($job); + $this->fail('Expected staging cancellation to propagate.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame([], $failed); + $this->assertSame(0, DB::table('job_outbox')->count()); + } + public function testAcceptedBulkIsNotFailedWhenItsQueuedListenerThrows(): void { $queue = $this->queue(); diff --git a/tests/Queue/Outbox/OutboxRelayTest.php b/tests/Queue/Outbox/OutboxRelayTest.php index dd935d07a1..2b8c0e00e0 100644 --- a/tests/Queue/Outbox/OutboxRelayTest.php +++ b/tests/Queue/Outbox/OutboxRelayTest.php @@ -23,6 +23,7 @@ use LogicException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -254,22 +255,49 @@ public static function reportingOutcomes(): array return [[false], [true]]; } - public function testIndependentPhaseCancellationReleasesResourcesAndStopsWithoutSettlement(): void + #[TestWith(['sequential', false])] + #[TestWith(['sequential', true])] + #[TestWith(['publication', true])] + #[TestWith(['settlement', true])] + #[TestWith(['notification', true])] + public function testIndependentPhaseCancellationReleasesResourcesAndStopsDelivery(string $phase, bool $cleanupThrows): void { $repository = m::mock(DatabaseOutboxRepository::class); $queues = m::mock(Factory::class); - $queue = m::mock(Queue::class); + $queue = m::mock($phase === 'sequential' ? Queue::class : Queue::class . ', ' . PublishesOutboxMessages::class); $exceptions = m::mock(ExceptionHandler::class); $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Phase cleanup failed.', previous: $cancellation); + } $released = false; $queues->expects('connection')->andReturn($queue); - $queue->expects('pushRaw')->andReturnUsing(static function () use ($cancellation, &$released): never { + $cancel = static function () use ($cancellation, &$released): never { Coroutine::defer(static function () use (&$released): void { $released = true; }); throw $cancellation; - }); + }; + $events = new Dispatcher; + + if ($phase === 'sequential') { + $queue->expects('pushRaw')->andReturnUsing($cancel); + } elseif ($phase === 'publication') { + $queue->expects('publishOutboxMessages')->andReturnUsing($cancel); + } else { + $queue->expects('publishOutboxMessages')->andReturn(new OutboxBatchResult([1])); + if ($phase === 'settlement') { + $repository->expects('settle')->andReturnUsing($cancel); + } else { + $repository->expects('settle')->andReturn(1); + $events->listen(OutboxBatchDelivered::class, $cancel); + } + } + $messages = [$this->message(1)]; + if ($phase !== 'notification') { + $messages[] = $this->message(2, 'other'); + } try { - (new OutboxRelay($repository, $queues, new Dispatcher, $exceptions))->deliver('primary', [$this->message(1), $this->message(2, 'other')]); + (new OutboxRelay($repository, $queues, $events, $exceptions))->deliver('primary', $messages); $this->fail('Child cancellation must stop delivery.'); } catch (ChildCancellationException $exception) { $this->assertSame($cancellation, $exception->getPrevious()); diff --git a/tests/Queue/Outbox/PooledOutboxPublicationTest.php b/tests/Queue/Outbox/PooledOutboxPublicationTest.php index fc6ee1909f..f2a200430b 100644 --- a/tests/Queue/Outbox/PooledOutboxPublicationTest.php +++ b/tests/Queue/Outbox/PooledOutboxPublicationTest.php @@ -16,8 +16,10 @@ use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class PooledOutboxPublicationTest extends TestCase { @@ -65,10 +67,15 @@ public function testSequentialFallbackRetainsConfirmedPrefixAndDefersFutureRows( } } - public function testCancellationReleasesTheBorrowedTransportAndRemainsCancellation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testCancellationReleasesTheBorrowedTransportAndRemainsCancellation(bool $cleanupThrows): void { $queue = m::mock(SyncQueue::class)->makePartial(); $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Transport cleanup failed.', previous: $cancellation); + } $queue->expects('pushRaw')->andThrow($cancellation); $released = 0; $pools = new PoolManager; @@ -78,7 +85,7 @@ public function testCancellationReleasesTheBorrowedTransportAndRemainsCancellati try { $proxy->publishOutboxMessages([$this->message(1)]); $this->fail('Expected publication to remain cancelled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Queue/Outbox/RedisOutboxPublicationTest.php b/tests/Queue/Outbox/RedisOutboxPublicationTest.php index 0ad0b723ed..742986de54 100644 --- a/tests/Queue/Outbox/RedisOutboxPublicationTest.php +++ b/tests/Queue/Outbox/RedisOutboxPublicationTest.php @@ -22,7 +22,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class RedisOutboxPublicationTest extends TestCase { @@ -56,15 +59,30 @@ public static function clusterModes(): array return [[false], [true]]; } - public function testLaterDestinationFailurePreservesEarlierConfirmation(): void + #[TestWith(['ordinary'])] + #[TestWith(['cancellation'])] + #[TestWith(['cleanup'])] + public function testLaterDestinationFailurePreservesEarlierConfirmation(string $failureType): void { [$queue, $connection] = $this->queue(); - $failure = new RuntimeException('Redis connection lost.'); + $failure = match ($failureType) { + 'cancellation' => new CanceledException, + 'cleanup' => new RuntimeException('Redis cleanup failed.', previous: new CanceledException), + default => new RuntimeException('Redis connection lost.'), + }; $connection->expects('evalWithShaCache')->withArgs(fn (string $script, array $keys): bool => $keys[0] === 'queues:first')->andReturn(1); $connection->expects('evalWithShaCache')->withArgs(fn (string $script, array $keys): bool => $keys[0] === 'queues:second')->andThrow($failure); - $result = $queue->publishOutboxMessages([$this->message(1, 'first'), $this->message(2, 'second'), $this->message(3, 'third')]); + try { + $result = $queue->publishOutboxMessages([$this->message(1, 'first'), $this->message(2, 'second'), $this->message(3, 'third')]); + } catch (Throwable $exception) { + $this->assertNotSame('ordinary', $failureType); + $this->assertSame($failure, $exception); + return; + } + + $this->assertSame('ordinary', $failureType, 'Cancellation must propagate instead of returning a batch result.'); $this->assertSame([1], $result->accepted); $this->assertSame($failure, $result->failure); } diff --git a/tests/Queue/Outbox/SqsOutboxPublicationTest.php b/tests/Queue/Outbox/SqsOutboxPublicationTest.php index ecb0483483..1f18951ebb 100644 --- a/tests/Queue/Outbox/SqsOutboxPublicationTest.php +++ b/tests/Queue/Outbox/SqsOutboxPublicationTest.php @@ -18,7 +18,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class SqsOutboxPublicationTest extends TestCase { @@ -61,11 +64,18 @@ public static function deliveryTimes(): array return [[null, false, null], [0, false, 0], [-30, false, 0], [30, false, 30], [1000, false, 900], [0, true, null]]; } - public function testBatchSizeLimitsAndLaterSdkFailureRetainEarlierConfirmation(): void + #[TestWith(['ordinary'])] + #[TestWith(['cancellation'])] + #[TestWith(['cleanup'])] + public function testBatchSizeLimitsAndLaterSdkFailureRetainEarlierConfirmation(string $failureType): void { $client = m::mock(SqsClient::class); $queue = new SqsQueue($client, 'orders'); - $failure = new RuntimeException('SDK retries exhausted.'); + $failure = match ($failureType) { + 'cancellation' => new CanceledException, + 'cleanup' => new RuntimeException('SDK cleanup failed.', previous: new CanceledException), + default => new RuntimeException('SDK retries exhausted.'), + }; $calls = 0; $client->expects('sendMessageBatch')->twice()->andReturnUsing(function (array $arguments) use (&$calls, $failure): Result { if (++$calls === 2) { @@ -78,8 +88,16 @@ public function testBatchSizeLimitsAndLaterSdkFailureRetainEarlierConfirmation() return new Result(['Successful' => array_map(static fn (array $entry): array => ['Id' => $entry['Id'], 'MessageId' => 'sqs-' . $entry['Id']], $arguments['Entries'])]); }); - $result = $queue->publishOutboxMessages(array_map(fn (int $id): OutboxMessage => $this->message($id), range(1, 11))); + try { + $result = $queue->publishOutboxMessages(array_map(fn (int $id): OutboxMessage => $this->message($id), range(1, 11))); + } catch (Throwable $exception) { + $this->assertNotSame('ordinary', $failureType); + $this->assertSame($failure, $exception); + return; + } + + $this->assertSame('ordinary', $failureType, 'Cancellation must propagate instead of returning a batch result.'); $this->assertSame(range(1, 10), $result->accepted); $this->assertSame($failure, $result->failure); } diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index da1049d096..94b127fcc0 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -51,6 +51,7 @@ use PDO; use PDOException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; use stdClass; @@ -153,14 +154,14 @@ public static function transientReservationFailureProvider(): array } #[DataProvider('reservationCleanupFailureProvider')] - public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(Throwable $cleanupFailure): void + public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(Throwable $cleanupFailure, bool $canceled): void { $failure = new QueryException('database', 'update jobs', [], new PDOException('Reservation failed.')); [$queue, $events] = $this->createFailingReservationQueue($failure); $queue->shouldReceive('causedByReservationQuery')->once()->andReturn(true); $queue->shouldReceive('deleteReserved')->once()->with('default', '1')->andThrow($cleanupFailure); - if ($cleanupFailure instanceof CanceledException) { + if ($canceled) { $events->shouldReceive('dispatch')->never(); } else { $events->shouldReceive('hasListeners')->once()->with(JobFailed::class)->andReturn(false); @@ -170,7 +171,7 @@ public function testReservationRecoveryPreservesFailureOrPropagatesCancellation( $queue->pop(); $this->fail('Expected the reservation or cancellation failure.'); } catch (Throwable $exception) { - $this->assertSame($cleanupFailure instanceof CanceledException ? $cleanupFailure : $failure, $exception); + $this->assertSame($canceled ? $cleanupFailure : $failure, $exception); } } @@ -180,8 +181,9 @@ public function testReservationRecoveryPreservesFailureOrPropagatesCancellation( public static function reservationCleanupFailureProvider(): array { return [ - 'ordinary failure' => [new RuntimeException('Deletion failed.')], - 'cancellation' => [new CanceledException('Deletion canceled.')], + 'ordinary failure' => [new RuntimeException('Deletion failed.'), false], + 'cancellation' => [new CanceledException('Deletion canceled.'), true], + 'cleanup during cancellation' => [new RuntimeException('Deletion cleanup failed.', previous: new CanceledException), true], ]; } @@ -724,7 +726,54 @@ public function testBulkRaisesExactFailureEventsWhenInsertThrows(): void $this->assertSame($exception, $events[3]->exception); } - public function testBulkCancellationDoesNotRaiseFailureEvents(): void + #[TestWith([false])] + #[TestWith([true])] + public function testOutboxSelectionCancellationDoesNotRaiseFailureEvents(bool $cleanupThrows): void + { + $queue = new DatabaseQueue(m::mock(ConnectionResolverInterface::class), null, 'jobs', 'default'); + $queue->setConnectionName('database'); + $container = new Container; + $events = new Dispatcher($container); + $container->instance('events', $events); + $queue->setContainer($container); + $gate = $this->armCurrentCoroutineCancellation(); + $cleanup = new RuntimeException('Transaction resolution cleanup failed.'); + $container->bind('db.transactions', static function () use ($gate, $cleanup, $cleanupThrows): never { + try { + $gate->push(true); + } finally { + if ($cleanupThrows) { + throw $cleanup; + } + } + + throw new RuntimeException('Cancellation was not delivered.'); + }); + $failed = []; + $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void { + $failed[] = $event; + }); + + try { + $queue->push((object) ['viaOutbox' => true]); + $this->fail('Expected cancellation to escape outbox selection.'); + } catch (Throwable $exception) { + if ($cleanupThrows) { + $this->assertSame($cleanup, $exception); + $this->assertInstanceOf(CanceledException::class, $exception->getPrevious()); + } else { + $this->assertInstanceOf(CanceledException::class, $exception); + } + + $this->assertSame([], $failed); + } finally { + $gate->close(); + } + } + + #[TestWith([false])] + #[TestWith([true])] + public function testBulkCancellationDoesNotRaiseFailureEvents(bool $cleanupThrows): void { $queue = new TestDatabaseQueue( resolver: $resolver = m::mock(ConnectionResolverInterface::class), @@ -742,8 +791,15 @@ public function testBulkCancellationDoesNotRaiseFailureEvents(): void $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once(); $gate = $this->armCurrentCoroutineCancellation(); - $query->shouldReceive('insert')->once()->andReturnUsing(static function () use ($gate): never { - $gate->push(true); + $cleanup = new RuntimeException('Insert cleanup failed.'); + $query->shouldReceive('insert')->once()->andReturnUsing(static function () use ($gate, $cleanup, $cleanupThrows): never { + try { + $gate->push(true); + } finally { + if ($cleanupThrows) { + throw $cleanup; + } + } throw new RuntimeException('Cancellation was not delivered.'); }); @@ -759,11 +815,19 @@ public function testBulkCancellationDoesNotRaiseFailureEvents(): void try { $queue->bulk(['first', 'second'], queue: 'emails'); $this->fail('Expected cancellation to escape the bulk insert.'); - } catch (CanceledException) { + } catch (Throwable $exception) { + if ($cleanupThrows) { + $this->assertSame($cleanup, $exception); + $this->assertInstanceOf(CanceledException::class, $exception->getPrevious()); + } else { + $this->assertInstanceOf(CanceledException::class, $exception); + } $this->assertSame([ JobQueueing::class, JobQueueing::class, ], array_map(static fn (object $event): string => $event::class, $events)); + } finally { + $gate->close(); } } diff --git a/tests/Queue/QueuePoolProxyTest.php b/tests/Queue/QueuePoolProxyTest.php index 5df7333ed4..87ebac8682 100644 --- a/tests/Queue/QueuePoolProxyTest.php +++ b/tests/Queue/QueuePoolProxyTest.php @@ -34,6 +34,7 @@ use Pheanstalk\Contract\PheanstalkPublisherInterface; use Pheanstalk\Contract\PheanstalkSubscriberInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -456,10 +457,15 @@ public static function beanstalkAttachmentFailureDataProvider(): array ]; } - public function testAttachmentRecoveryCancellationSupersedesTheAttachmentFailure(): void + #[TestWith([false])] + #[TestWith([true])] + public function testAttachmentRecoveryCancellationSupersedesTheAttachmentFailure(bool $cleanupThrows): void { $attachmentException = new Exception('stats failed'); $recoveryCancellation = new CanceledException('release canceled'); + if ($cleanupThrows) { + $recoveryCancellation = new RuntimeException('Recovery cleanup failed.', previous: $recoveryCancellation); + } $pheanstalk = m::mock(implode(',', [ PheanstalkManagerInterface::class, PheanstalkPublisherInterface::class, @@ -493,9 +499,14 @@ public function testAttachmentRecoveryCancellationSupersedesTheAttachmentFailure $this->assertSame(0, $pool->getManagedCount()); } - public function testAttachmentCancellationDoesNotStartBackendRecovery(): void + #[TestWith([false])] + #[TestWith([true])] + public function testAttachmentCancellationDoesNotStartBackendRecovery(bool $cleanupThrows): void { $attachmentCancellation = new CanceledException('stats canceled'); + if ($cleanupThrows) { + $attachmentCancellation = new RuntimeException('Attachment cleanup failed.', previous: $attachmentCancellation); + } $pheanstalk = m::mock(implode(',', [ PheanstalkManagerInterface::class, PheanstalkPublisherInterface::class, diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index 42030a6712..55da0baf9f 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -33,8 +33,11 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Symfony\Component\Uid\Uuid; +use Throwable; class QueueRedisQueueTest extends TestCase { @@ -160,6 +163,38 @@ public function testBulkUsesOneClusterSafeLuaCall(): void $this->assertNull($queue->bulk(['first', 'second'])); } + #[TestWith([false])] + #[TestWith([true])] + public function testBulkCancellationDoesNotRaiseFailureEvents(bool $cleanupThrows): void + { + $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Redis cleanup failed.', previous: $cancellation); + } + $connection = $this->mockRedisProxyWithShaCache(); + $connection->expects('isCluster')->andReturnFalse(); + $connection->expects('evalWithShaCache')->andThrow($cancellation); + $redis = m::mock(Redis::class); + $redis->expects('connection')->with('default')->andReturn($connection); + $queue = new RedisQueue($redis, 'default', 'default'); + $events = new EventDispatcher($container = new Container); + $container->instance('events', $events); + $queue->setContainer($container)->setConnectionName('redis'); + $failed = []; + $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void { + $failed[] = $event; + }); + + try { + $queue->bulk(['first', 'second']); + $this->fail('Expected bulk cancellation to propagate.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame([], $failed); + } + public function testEmptyBulkDoesNotResolveRedis(): void { $redis = m::mock(Redis::class); diff --git a/tests/Queue/QueueSqsJobTest.php b/tests/Queue/QueueSqsJobTest.php index 957691c160..4a2385519f 100644 --- a/tests/Queue/QueueSqsJobTest.php +++ b/tests/Queue/QueueSqsJobTest.php @@ -19,6 +19,7 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; @@ -434,9 +435,14 @@ public function testDeleteCleansOverflowPayloadAfterLeaseReleaseFails(): void } } - public function testDeleteDoesNotStartOverflowCleanupAfterLeaseReleaseCancellation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDeleteDoesNotStartOverflowCleanupAfterLeaseReleaseCancellation(bool $cleanupThrows): void { $releaseCancellation = new CanceledException('release canceled'); + if ($cleanupThrows) { + $releaseCancellation = new RuntimeException('Release cleanup failed.', previous: $releaseCancellation); + } [, $lease] = $this->lease( releaseCallback: static fn () => throw $releaseCancellation, ); @@ -550,10 +556,15 @@ function (Throwable $exception) use (&$cleanupFailure): bool { $this->assertSame("Unable to delete the SQS overflow payload [{$pointer}].", $cleanupFailure->getMessage()); } - public function testOverflowCleanupCancellationSupersedesAnOrdinaryLeaseReleaseFailure(): void + #[TestWith([false])] + #[TestWith([true])] + public function testOverflowCleanupCancellationSupersedesAnOrdinaryLeaseReleaseFailure(bool $cleanupThrows): void { $releaseFailure = new Exception('release failed'); $cleanupCancellation = new CanceledException('overflow cleanup canceled'); + if ($cleanupThrows) { + $cleanupCancellation = new RuntimeException('Overflow cleanup failed.', previous: $cleanupCancellation); + } [, $lease] = $this->lease( releaseCallback: static fn () => throw $releaseFailure, ); diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index 1e54c14e22..8fc9ebb5fd 100644 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -17,6 +17,7 @@ use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Cache\Store as CacheStore; use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; @@ -44,9 +45,11 @@ use LogicException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Symfony\Component\Uid\Uuid; +use Throwable; class QueueSqsQueueTest extends TestCase { @@ -1031,6 +1034,55 @@ public static function nonPositiveFifoDelays(): array ]; } + #[TestWith(['finalizing'])] + #[TestWith(['queueing'])] + #[TestWith(['stored'])] + #[TestWith(['queued'])] + public function testPushCancellationStopsSubsequentLifecycleNotifications(string $phase): void + { + $container = new Container; + $events = new ConcreteEventDispatcher($container); + $container->instance('events', $events); + $cancellation = new RuntimeException('Listener cleanup failed.', previous: new CanceledException); + $notified = []; + + foreach ([JobPayloadFinalizing::class => 'finalizing', JobQueueing::class => 'queueing', JobQueued::class => 'queued', JobQueueingFailed::class => 'failed'] as $eventClass => $name) { + $events->listen($eventClass, static function () use ($name, $phase, $cancellation, &$notified): void { + $notified[] = $name; + if ($name === $phase) { + throw $cancellation; + } + }); + } + + $queue = m::mock(SqsQueue::class, [$this->sqs, $this->queueName, $this->prefix]) + ->makePartial()->shouldAllowMockingProtectedMethods(); + $queue->setContainer($container); + $queue->setConnectionName('sqs'); + + if (in_array($phase, ['stored', 'queued'], true)) { + $this->sqs->expects('sendMessage')->andReturn($this->mockedSendMessageResponseModel); + $queue->expects('handlePayloadPushed')->andThrow( + $phase === 'stored' ? $cancellation : new RuntimeException('Earlier notification failed.'), + ); + } else { + $this->sqs->shouldNotReceive('sendMessage'); + } + + try { + $queue->push('job'); + $this->fail('Expected cancellation to escape dispatch.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame(match ($phase) { + 'finalizing' => ['finalizing'], + 'queued' => ['finalizing', 'queueing', 'queued'], + default => ['finalizing', 'queueing'], + }, $notified); + } + public function testPushRawStoresPayloadToCacheWhenExceedingThreshold(): void { $uuid = 'test-uuid'; @@ -1213,14 +1265,25 @@ public static function invalidOverflowUuidProvider(): array ]; } - public function testPushRawFailsBeforeSendingWhenOverflowStorageReturnsFalse(): void + #[TestWith([null])] + #[TestWith([false])] + #[TestWith([true])] + public function testPushRawFailsBeforeSendingWhenOverflowStorageReturnsFalse(?bool $cleanupThrows): void { $payload = json_encode(['uuid' => 'failed-write'], JSON_THROW_ON_ERROR); $path = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'failed-write'; + $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Delete cleanup failed.', previous: $cancellation); + } $store = m::mock(CacheRepository::class); $store->expects('put')->andReturnFalse(); - $store->expects('forget')->with($path)->andReturnTrue(); + if ($cleanupThrows === null) { + $store->expects('forget')->with($path)->andReturnTrue(); + } else { + $store->expects('forget')->with($path)->andThrow($cancellation); + } $cache = m::mock(CacheFactory::class); $cache->expects('store')->with('database')->andReturn($store); @@ -1238,21 +1301,44 @@ public function testPushRawFailsBeforeSendingWhenOverflowStorageReturnsFalse(): $this->sqs->shouldNotReceive('sendMessage'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessageIsOrContains('Unable to store the SQS overflow payload'); - - $queue->pushRaw($payload, $this->queueName); + try { + $queue->pushRaw($payload, $this->queueName); + $this->fail('Expected the overflow write or cleanup failure.'); + } catch (Throwable $exception) { + if ($cleanupThrows === null) { + $this->assertInstanceOf(RuntimeException::class, $exception); + $this->assertStringContainsString('Unable to store the SQS overflow payload', $exception->getMessage()); + } else { + $this->assertSame($cancellation, $exception); + } + } } - public function testPushRawPreservesCancellationAndCleansTheAttemptedOverflowPayload(): void + #[DataProvider('overflowCancellationProvider')] + public function testPushRawPreservesCancellationAndCleansTheAttemptedOverflowPayload(bool $writeCleanupThrows, ?bool $deleteCleanupThrows): void { $payload = json_encode(['uuid' => 'canceled-write'], JSON_THROW_ON_ERROR); $path = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'canceled-write'; $cancellation = new CanceledException; + if ($writeCleanupThrows) { + $cancellation = new RuntimeException('write cleanup failed', previous: $cancellation); + } + $cleanupCancellation = new CanceledException; + if ($deleteCleanupThrows) { + $cleanupCancellation = new RuntimeException('delete cleanup failed', previous: $cleanupCancellation); + } + + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->with($cleanupCancellation)->times($deleteCleanupThrows ? 1 : 0); + Container::getInstance()->instance(ExceptionHandler::class, $handler); $store = m::mock(CacheRepository::class); $store->expects('put')->with($path, $payload)->andThrow($cancellation); - $store->expects('forget')->with($path)->andReturnTrue(); + if ($deleteCleanupThrows === null) { + $store->expects('forget')->with($path)->andReturnTrue(); + } else { + $store->expects('forget')->with($path)->andThrow($cleanupCancellation); + } $cache = m::mock(CacheFactory::class); $cache->expects('store')->with('database')->andReturn($store); @@ -1273,11 +1359,25 @@ public function testPushRawPreservesCancellationAndCleansTheAttemptedOverflowPay try { $queue->pushRaw($payload, $this->queueName); $this->fail('Expected the overflow write cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } + /** + * Provide native cancellations and errors thrown while cancellation unwinds. + */ + public static function overflowCancellationProvider(): array + { + return [ + 'successful cleanup' => [false, null], + 'native write and delete' => [false, false], + 'chained write' => [true, false], + 'chained delete' => [false, true], + 'chained write and delete' => [true, true], + ]; + } + public function testPushRawRetainsOverflowPayloadWhenSqsDeliveryIsAmbiguous(): void { $payload = json_encode(['uuid' => 'ambiguous'], JSON_THROW_ON_ERROR); @@ -1655,6 +1755,30 @@ public function testBulkRaisesQueueingAndQueuedEventsForEachJob(): void $this->assertSame(['mid-0', 'mid-1'], array_map(fn (JobQueued $event): mixed => $event->id, array_values($queuedEvents))); } + public function testBulkNormalizesEnumQueueNameInEvents(): void + { + $container = new Container; + $events = new ConcreteEventDispatcher($container); + $container->instance('events', $events); + $queues = []; + + $events->listen([JobQueueing::class, JobQueued::class], function (JobQueueing|JobQueued $event) use (&$queues): void { + $queues[] = $event->queue; + }); + + $queue = new SqsQueue($this->sqs, $this->queueName, $this->prefix); + $queue->setContainer($container); + $queue->setConnectionName('sqs'); + $this->sqs->expects('sendMessageBatch')->andReturn(new Result([ + 'Successful' => [['Id' => '0', 'MessageId' => 'mid-0']], + 'Failed' => [], + ])); + + $queue->bulk(['a'], 'data', SqsQueueName::Emails); + + $this->assertSame([$this->queueName, $this->queueName], $queues); + } + public function testBulkHonoursPerJobDelay(): void { $jobA = new FakeSqsJob; @@ -1993,7 +2117,8 @@ static function (object $event) use (&$dispatched): object { $this->assertSame($failed[0]->exception, $failed[10]->exception); } - public function testBulkPreservesWriteCancellationWhileDrainingAttemptedPointers(): void + #[DataProvider('overflowCancellationProvider')] + public function testBulkPreservesWriteCancellationWhileDrainingAttemptedPointers(bool $writeCleanupThrows, ?bool $deleteCleanupThrows): void { $firstPayload = json_encode(['uuid' => 'first'], JSON_THROW_ON_ERROR); $secondPayload = json_encode(['uuid' => 'second'], JSON_THROW_ON_ERROR); @@ -2001,11 +2126,25 @@ public function testBulkPreservesWriteCancellationWhileDrainingAttemptedPointers $secondPath = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'second'; $cancellation = new CanceledException('write canceled'); $cleanupCancellation = new CanceledException('cleanup canceled'); + if ($writeCleanupThrows) { + $cancellation = new RuntimeException('write cleanup failed', previous: $cancellation); + } + if ($deleteCleanupThrows) { + $cleanupCancellation = new RuntimeException('delete cleanup failed', previous: $cleanupCancellation); + } + + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->with($cleanupCancellation)->times($deleteCleanupThrows ? 1 : 0); + Container::getInstance()->instance(ExceptionHandler::class, $handler); $store = m::mock(CacheRepository::class); $store->expects('put')->with($firstPath, $firstPayload)->ordered()->andReturnTrue(); $store->expects('put')->with($secondPath, $secondPayload)->ordered()->andThrow($cancellation); - $store->expects('forget')->with($firstPath)->ordered()->andThrow($cleanupCancellation); + if ($deleteCleanupThrows === null) { + $store->expects('forget')->with($firstPath)->ordered()->andReturnTrue(); + } else { + $store->expects('forget')->with($firstPath)->ordered()->andThrow($cleanupCancellation); + } $store->expects('forget')->with($secondPath)->ordered()->andReturnTrue(); $cache = m::mock(CacheFactory::class); @@ -2052,7 +2191,7 @@ static function (object $event) use (&$dispatched): object { try { $queue->bulk(['a', 'b'], 'data', $this->queueName); $this->fail('Expected the overflow write cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2062,13 +2201,18 @@ static function (object $event) use (&$dispatched): object { ); } - public function testBulkCleanupCancellationSupersedesAnOrdinaryWriteFailure(): void + #[TestWith([false])] + #[TestWith([true])] + public function testBulkCleanupCancellationSupersedesAnOrdinaryWriteFailure(bool $cleanupThrows): void { $firstPayload = json_encode(['uuid' => 'first'], JSON_THROW_ON_ERROR); $secondPayload = json_encode(['uuid' => 'second'], JSON_THROW_ON_ERROR); $firstPath = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'first'; $secondPath = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'second'; $cleanupCancellation = new CanceledException; + if ($cleanupThrows) { + $cleanupCancellation = new RuntimeException('delete cleanup failed', previous: $cleanupCancellation); + } $store = m::mock(CacheRepository::class); $store->expects('put')->with($firstPath, $firstPayload)->ordered()->andReturnTrue(); @@ -2105,16 +2249,21 @@ public function testBulkCleanupCancellationSupersedesAnOrdinaryWriteFailure(): v try { $queue->bulk(['a', 'b'], 'data', $this->queueName); $this->fail('Expected cleanup cancellation to supersede the write failure.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cleanupCancellation, $exception); } } - public function testBulkRetainsPointersAndSuppressesFailureEventsWhenSqsDeliveryIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testBulkRetainsPointersAndSuppressesFailureEventsWhenSqsDeliveryIsCanceled(bool $cleanupThrows): void { $payload = json_encode(['uuid' => 'canceled-batch'], JSON_THROW_ON_ERROR); $path = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'canceled-batch'; $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('transport cleanup failed', previous: $cancellation); + } $store = m::mock(CacheRepository::class); $store->expects('put')->with($path, $payload)->andReturnTrue(); @@ -2161,7 +2310,7 @@ static function (object $event) use (&$dispatched): object { try { $queue->bulk(['a'], 'data', $this->queueName); $this->fail('Expected the SQS delivery cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -2620,3 +2769,8 @@ public function testPopPassesOverflowStorageOptionsToJob(): void class SqsBulkAttributeDelayJob { } + +enum SqsQueueName: string +{ + case Emails = 'emails'; +} diff --git a/tests/Queue/SqsBatchLifecycleTest.php b/tests/Queue/SqsBatchLifecycleTest.php index 8c4343b430..3215a76d1e 100644 --- a/tests/Queue/SqsBatchLifecycleTest.php +++ b/tests/Queue/SqsBatchLifecycleTest.php @@ -19,6 +19,8 @@ use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Database\DatabaseTransactionsManager; +use Hypervel\Engine\Channel; +use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Events\Dispatcher; use Hypervel\Foundation\Bus\PendingDispatch; use Hypervel\Queue\Events\JobPayloadFinalizing; @@ -31,6 +33,8 @@ use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class SqsBatchLifecycleTest extends TestCase { @@ -94,6 +98,38 @@ public function testPreparationFailureTerminatesEveryOutstandingMember(string $e $this->assertSame([$exception, $exception, $exception], array_column($failed, 'exception')); } + #[TestWith([JobPayloadFinalizing::class])] + #[TestWith([JobQueueing::class])] + public function testPreparationCancellationDoesNotNotifyOutstandingMembers(string $eventClass): void + { + [$queue, $client, $events] = $this->queue(); + $cleanup = new RuntimeException('Preparation cleanup failed.'); + $gate = $this->armCurrentCoroutineCancellation(); + $events->listen($eventClass, static function () use ($gate, $cleanup): never { + try { + $gate->push(true); + } finally { + throw $cleanup; + } + }); + $failed = []; + $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void { + $failed[] = $event; + }); + $client->shouldNotReceive('sendMessageBatch'); + + try { + $queue->bulk(['first', 'second']); + $this->fail('Expected preparation cancellation.'); + } catch (Throwable $caught) { + $this->assertSame($cleanup, $caught); + $this->assertInstanceOf(CanceledException::class, $caught->getPrevious()); + $this->assertSame([], $failed); + } finally { + $gate->close(); + } + } + public function testFailureNotificationsDrainAndRetainThePrimaryAttemptError(): void { [$queue, $client, $events] = $this->queue(); @@ -117,7 +153,54 @@ public function testFailureNotificationsDrainAndRetainThePrimaryAttemptError(): $this->assertSame([$primary, $primary], array_column($failed, 'exception')); } - public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExplicitRejects(): void + #[TestWith([JobQueued::class])] + #[TestWith([JobQueueingFailed::class])] + public function testNotificationCancellationStopsAfterAnEarlierOrdinaryFailure(string $eventClass): void + { + [$queue, $client, $events] = $this->queue(); + if ($eventClass === JobQueued::class) { + $client->expects('sendMessageBatch')->andReturn(new Result([ + 'Successful' => [ + ['Id' => '0', 'MessageId' => 'first'], + ['Id' => '1', 'MessageId' => 'second'], + ['Id' => '2', 'MessageId' => 'third'], + ], + ])); + } else { + $client->expects('sendMessageBatch')->andThrow(new RuntimeException('Transport failed.')); + } + $cleanup = new RuntimeException('Notification cleanup failed.'); + $gate = $this->armCurrentCoroutineCancellation(); + $notified = []; + $events->listen($eventClass, static function (JobQueued|JobQueueingFailed $event) use ($gate, $cleanup, &$notified): void { + $notified[] = $event->job; + if ($event->job === 'first') { + throw new RuntimeException('First listener failed.'); + } + if ($event->job === 'second') { + try { + $gate->push(true); + } finally { + throw $cleanup; + } + } + }); + + try { + $queue->bulk(['first', 'second', 'third']); + $this->fail('Expected notification cancellation.'); + } catch (Throwable $caught) { + $this->assertSame($cleanup, $caught); + $this->assertInstanceOf(CanceledException::class, $caught->getPrevious()); + $this->assertSame(['first', 'second'], $notified); + } finally { + $gate->close(); + } + } + + #[TestWith([false])] + #[TestWith([true])] + public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExplicitRejects(bool $failureListenerCanceled): void { [$queue, $client, $events, $container] = $this->queue(['enabled' => true, 'always' => true, 'store' => 'overflow']); $store = new Repository(new WorkerArrayStore); @@ -135,23 +218,27 @@ public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExpli }); $queued = $failed = []; $listener = new RuntimeException('Monitoring failed.'); + $cancellation = new RuntimeException('Failure listener cleanup failed.', previous: new CanceledException); $events->listen(JobQueued::class, static function (JobQueued $event) use (&$queued): void { $queued[] = $event->job; }); $events->listen(JobQueued::class, static fn () => throw $listener); - $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void { + $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed, $failureListenerCanceled, $cancellation): void { $failed[] = $event->job; + if ($failureListenerCanceled) { + throw $cancellation; + } }); try { $queue->bulk(array_map('strval', range(1, 11))); $this->fail('The monitoring failure should be rethrown.'); } catch (RuntimeException $caught) { - $this->assertSame($listener, $caught); + $this->assertSame($failureListenerCanceled ? $cancellation : $listener, $caught); } $this->assertSame(array_map('strval', range(1, 9)), $queued); - $this->assertSame(['10', '11'], $failed); + $this->assertSame($failureListenerCanceled ? ['10'] : ['10', '11'], $failed); $this->assertFalse($store->has($paths[9])); foreach (array_slice($paths, 0, 9) as $path) { @@ -159,6 +246,23 @@ public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExpli } } + /** + * Arm cancellation at a controlled channel handoff. + */ + private function armCurrentCoroutineCancellation(): Channel + { + $gate = new Channel(1); + $coroutineId = EngineCoroutine::id(); + + EngineCoroutine::create(static function () use ($coroutineId, $gate): void { + if ($gate->pop(5)) { + EngineCoroutine::cancelById($coroutineId, throwException: true); + } + }); + + return $gate; + } + /** * Create a queue with real lifecycle dispatch and a controlled SDK boundary. * diff --git a/tests/Routing/RouteRegistrarTest.php b/tests/Routing/RouteRegistrarTest.php index 883972bc48..c95eb64724 100644 --- a/tests/Routing/RouteRegistrarTest.php +++ b/tests/Routing/RouteRegistrarTest.php @@ -230,6 +230,15 @@ public function testCanRegisterQueryRouteWithClosureAction(): void $this->seeMiddleware('query-middleware'); } + public function testCanRegisterQueryRouteWithControllerAction(): void + { + $this->router->middleware('query-middleware') + ->query('users', RouteRegistrarControllerStub::class . '@index'); + + $this->seeResponse('controller', Request::create('users', 'QUERY')); + $this->seeMiddleware('query-middleware'); + } + public function testCanRegisterAnyRouteWithClosureAction(): void { $this->router->middleware('test-middleware')->any('users', function () { diff --git a/tests/Routing/RoutingResponseFactoryTest.php b/tests/Routing/RoutingResponseFactoryTest.php index a83f4a2cfa..3e11b657ea 100644 --- a/tests/Routing/RoutingResponseFactoryTest.php +++ b/tests/Routing/RoutingResponseFactoryTest.php @@ -15,8 +15,11 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; class RoutingResponseFactoryTest extends TestCase { @@ -83,14 +86,23 @@ public function testStreamSupportsInvokableGeneratorCallables(): void $this->assertSame(['invokable'], $chunks); } - public function testStreamKeepsOrdinaryCallbacksOnSymfonyResponse(): void + #[TestWith([false])] + #[TestWith([true])] + public function testStreamKeepsOrdinaryCallbacksOnSymfonyResponse(bool $setCallbackLater): void { - $response = $this->factory()->stream(static function (): void { + $callback = static function (): void { echo 'callback'; - }); + }; + $response = $this->factory()->stream($setCallbackLater ? null : $callback); + + if ($setCallbackLater) { + $response->setCallback($callback); + } $this->assertInstanceOf(StreamedResponse::class, $response); $this->assertNotInstanceOf(IterableStreamedResponse::class, $response); + $this->expectOutputString('callback'); + $response->sendContent(); } public function testEventStreamStopsProducingAfterAFailedWrite(): void @@ -158,26 +170,42 @@ static function () { } #[DataProvider('streamingFactoryMethods')] - public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrWrapping(string $method): void + public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrWrapping(string $method, bool $cleanupThrows): void { $failure = new CanceledException('response canceled'); + $cleanup = new RuntimeException('cleanup failed'); $handler = m::mock(ExceptionHandler::class); $handler->shouldNotReceive('report'); Container::getInstance()->instance(ExceptionHandler::class, $handler); + $operation = static function () use ($failure, $cleanup, $cleanupThrows): never { + try { + throw $failure; + } finally { + if ($cleanupThrows) { + throw $cleanup; + } + } + }; $callback = $method === 'eventStream' - ? static function () use ($failure): iterable { + ? static function () use ($operation): iterable { yield 'first'; - throw $failure; + $operation(); } - : static fn () => throw $failure; + : $operation; $response = $this->factory()->{$method}($callback); $this->expectOutputString($method === 'eventStream' ? "event: update\ndata: first\n\n" : ''); + $caught = null; try { $response->sendContent(); - $this->fail('Expected response production to propagate cancellation.'); - } catch (CanceledException $exception) { - $this->assertSame($failure, $exception); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cleanupThrows ? $cleanup : $failure, $caught); + + if ($cleanupThrows) { + $this->assertSame($failure, $cleanup->getPrevious()); } } @@ -186,7 +214,12 @@ public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrW */ public static function streamingFactoryMethods(): array { - return [['eventStream'], ['streamDownload']]; + return [ + 'event stream' => ['eventStream', false], + 'event stream cleanup' => ['eventStream', true], + 'download' => ['streamDownload', false], + 'download cleanup' => ['streamDownload', true], + ]; } private function factory(): ResponseFactory diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php index ef9d40bb05..a11ad345f0 100644 --- a/tests/Routing/RoutingRouteTest.php +++ b/tests/Routing/RoutingRouteTest.php @@ -53,15 +53,6 @@ class RoutingRouteTest extends TestCase { - protected function tearDown(): void - { - ResourceRegistrar::verbs(['create' => 'create', 'edit' => 'edit']); - ResourceRegistrar::setParameters(); - ResourceRegistrar::singularParameters(); - - parent::tearDown(); - } - public function testBasicDispatchingOfRoutes(): void { $router = $this->getRouter(); @@ -99,12 +90,8 @@ public function testBasicDispatchingOfRoutes(): void $router->post('foo/bar', function () { return 'post hello'; }); - $router->query('foo/bar', function (): string { - return 'query hello'; - }); $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'GET'))->getContent()); $this->assertSame('post hello', $router->dispatch(Request::create('foo/bar', 'POST'))->getContent()); - $this->assertSame('query hello', $router->dispatch(Request::create('foo/bar', 'QUERY'))->getContent()); $router = $this->getRouter(); $router->get('foo/{bar}', function ($name) { @@ -174,7 +161,6 @@ public function testBasicDispatchingOfRoutes(): void return 'hello'; }); $this->assertEmpty($router->dispatch(Request::create('foo/bar', 'HEAD'))->getContent()); - $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'QUERY'))->getContent()); $router = $this->getRouter(); $router->get('foo/bar', function () { @@ -559,6 +545,45 @@ public function testNullValuesCanBeInjectedIntoRoutes(): void $router->dispatch(Request::create('foo/bar/baz', 'GET'))->getContent(); } + public function testQueryRoutesAreDispatched(): void + { + $router = $this->getRouter(); + $router->query('products/search', function () use ($router): mixed { + return $router->getCurrentRequest()->input('name'); + }); + $router->get('products/search', fn (): string => 'get'); + $router->post('products/search', fn (): string => 'post'); + + $request = Request::create('products/search', 'QUERY', ['name' => 'taylor']); + + $this->assertSame('taylor', $router->dispatch($request)->getContent()); + $this->assertSame(['QUERY'], $router->getRoutes()->getRoutes()[0]->methods()); + $this->assertSame('get', $router->dispatch(Request::create('products/search', 'GET'))->getContent()); + $this->assertSame('post', $router->dispatch(Request::create('products/search', 'POST'))->getContent()); + } + + public function testQueryRoutesReceiveJsonBody(): void + { + $router = $this->getRouter(); + $router->query('products/search', function () use ($router): mixed { + return $router->getCurrentRequest()->input('filters.name'); + }); + + $request = Request::create('products/search', 'QUERY', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['filters' => ['name' => 'taylor']])); + + $this->assertSame('taylor', $router->dispatch($request)->getContent()); + } + + public function testAnyRoutesRespondToQueryRequests(): void + { + $router = $this->getRouter(); + $router->any('foo', function (): string { + return 'hello'; + }); + + $this->assertSame('hello', $router->dispatch(Request::create('foo', 'QUERY'))->getContent()); + } + public function testOptionsResponsesAreGeneratedByDefault(): void { $router = $this->getRouter(); diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php index 52d192c933..59b0edf547 100644 --- a/tests/Support/SupportArrTest.php +++ b/tests/Support/SupportArrTest.php @@ -142,6 +142,10 @@ public function testCollapse(): void $collection = collect(['baz', 'boom']); $mixedArray = [[1], [2], [3], ['foo', 'bar'], $collection]; $this->assertEquals([1, 2, 3, 'foo', 'bar', 'baz', 'boom'], Arr::collapse($mixedArray)); + + // Case including lazy collections + $mixedArray = [[1], new LazyCollection([2, 3]), collect([4])]; + $this->assertEquals([1, 2, 3, 4], Arr::collapse($mixedArray)); } public function testCrossJoin(): void @@ -660,6 +664,12 @@ public function testFlatten(): void $this->assertEquals(['#foo', '#bar', '#zap', '#baz'], Arr::flatten($array)); } + public function testFlattenWithLazyCollections(): void + { + $array = [new LazyCollection(['#foo', ['#bar']]), ['#baz', new LazyCollection(['#zap'])]]; + $this->assertEquals(['#foo', '#bar', '#baz', '#zap'], Arr::flatten($array)); + } + public function testFlattenWithDepth(): void { // No depth flattens recursively diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index d3c95f84ee..3f40eac127 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -1457,6 +1457,13 @@ public function testFlatten($collection): void // Nested arrays containing collections containing arrays are flattened $c = new $collection([['#foo', new $collection(['#bar', ['#zap']])], ['#baz']]); $this->assertEquals(['#foo', '#bar', '#zap', '#baz'], $c->flatten()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testFlattenWithMixedCollectionTypes(string $collection): void + { + $c = new $collection([new Collection(['#foo', new LazyCollection(['#bar'])]), new LazyCollection(['#baz', new Collection(['#zap'])])]); + $this->assertSame(['#foo', '#bar', '#baz', '#zap'], $c->flatten()->all()); $c = new $collection([new LazyCollection(['#foo', ['#bar']]), ['#baz']]); $this->assertSame(['#foo', '#bar', '#baz'], $c->flatten()->all()); @@ -2016,9 +2023,13 @@ public function testCollapseWithNestedCollections($collection): void { $data = new $collection([new $collection([1, 2, 3]), new $collection([4, 5, 6])]); $this->assertEquals([1, 2, 3, 4, 5, 6], $data->collapse()->all()); + } - $data = new $collection([new LazyCollection([1, 2, 3]), [4, 5, 6]]); - $this->assertSame([1, 2, 3, 4, 5, 6], $data->collapse()->all()); + #[DataProvider('collectionClassProvider')] + public function testCollapseWithMixedCollectionTypes(string $collection): void + { + $data = new $collection([new Collection([1, 2]), new LazyCollection([3, 4]), [5]]); + $this->assertSame([1, 2, 3, 4, 5], $data->collapse()->all()); } #[DataProvider('collectionClassProvider')] @@ -2037,6 +2048,13 @@ public function testCollapseWithKeysOnNestedCollections($collection): void { $data = new $collection([new $collection(['a' => '1a', 'b' => '1b']), new $collection(['b' => '2b', 'c' => '2c']), 'drop']); $this->assertEquals(['a' => '1a', 'b' => '2b', 'c' => '2c'], $data->collapseWithKeys()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollapseWithKeysWithMixedCollectionTypes(string $collection): void + { + $data = new $collection([new Collection(['a' => 1, 'b' => 2]), new LazyCollection(['b' => 3, 'c' => 4])]); + $this->assertSame(['a' => 1, 'b' => 3, 'c' => 4], $data->collapseWithKeys()->all()); $data = new $collection([new LazyCollection(['a' => '1a', 'b' => '1b']), ['b' => '2b', 'c' => '2c'], 'drop']); $this->assertSame(['a' => '1a', 'b' => '2b', 'c' => '2c'], $data->collapseWithKeys()->all()); diff --git a/tests/Support/SupportIntervalFunctionsTest.php b/tests/Support/SupportDurationFunctionsTest.php similarity index 83% rename from tests/Support/SupportIntervalFunctionsTest.php rename to tests/Support/SupportDurationFunctionsTest.php index 058a6584cd..2d3da43cd4 100644 --- a/tests/Support/SupportIntervalFunctionsTest.php +++ b/tests/Support/SupportDurationFunctionsTest.php @@ -13,8 +13,24 @@ use function Hypervel\Support\minutes; use function Hypervel\Support\seconds; -class SupportIntervalFunctionsTest extends TestCase +class SupportDurationFunctionsTest extends TestCase { + public function testDurationFunctionsAcceptIntegers(): void + { + $this->assertEquals(90, seconds(90)->totalSeconds); + $this->assertEquals(300, minutes(5)->totalSeconds); + $this->assertEquals(7200, hours(2)->totalSeconds); + $this->assertEquals(172800, days(2)->totalSeconds); + } + + public function testDurationFunctionsKeepFractionalValues(): void + { + $this->assertEquals(1400, seconds(1.4)->totalMilliseconds); + $this->assertEquals(90, minutes(1.5)->totalSeconds); + $this->assertEquals(1800, hours(0.5)->totalSeconds); + $this->assertEquals(36, days(1.5)->totalHours); + } + #[DataProvider('intervalProvider')] public function testIntervalsPreserveWholeAndFractionalUnits( callable $helper, diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php index 7cd3ea2f19..c285caaac5 100644 --- a/tests/Support/SupportTestingQueueFakeTest.php +++ b/tests/Support/SupportTestingQueueFakeTest.php @@ -554,6 +554,22 @@ public function testFakedUniqueJobAcceptsDispatchOwnershipUntilFakeCleanup(): vo $this->assertTrue($lock->acquire(new QueueFakeUniqueJobStub)); } + public function testDirectPushDoesNotReleaseAnotherDispatchesUniqueLock(): void + { + $application = new Application; + $cache = new Repository(new WorkerArrayStore); + $application->instance(CacheContract::class, $cache); + $fake = new QueueFake($application); + $lock = new UniqueLock($cache); + + $this->assertTrue($lock->acquire(new QueueFakeUniqueJobStub)); + + $fake->push(new QueueFakeUniqueJobStub); + $fake->releaseUniqueJobLocks(); + + $this->assertFalse($lock->acquire(new QueueFakeUniqueJobStub)); + } + public function testItCanInvokeCallbacksBeforeAndAfterPushingFakedJobs(): void { $steps = [];