diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 4453b17a5c..ca2170ac0f 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -217,8 +217,7 @@ public function queue(mixed $event): void if (is_null($queue)) { $queue = $this->getAttributeValue($event, QueueAttribute::class, 'queue') - ?? $this->resolveQueueFromQueueRoute($event) - ?? null; + ?? $this->resolveQueueFromQueueRoute($event); } $broadcastEvent = $event instanceof ShouldBeUnique @@ -233,8 +232,7 @@ public function queue(mixed $event): void ->connection( $event->connection ?? $this->getAttributeValue($event, ConnectionAttribute::class, 'connection') - ?? $this->resolveConnectionFromQueueRoute($event) - ?? null + ?? $this->resolveConnectionFromQueueRoute($event, $queue) ) ->pushOn($queue, $broadcastEvent); @@ -254,6 +252,14 @@ public function queue(mixed $event): void : $push(); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->app; + } + /** * Determine if the broadcastable event must be unique and determine if we can acquire the necessary lock. */ diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index 67881ea316..64dbcef26b 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -287,6 +287,14 @@ public function dispatchAfterResponse(mixed $command, mixed $handler = null): vo } } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->container; + } + /** * Set the pipes through which commands should be piped before dispatching. * diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 5ea4f4371e..07186de265 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -1225,6 +1225,8 @@ public function slice(int $offset, ?int $length = null): static } /** + * Split a collection into a certain number of groups. + * * @throws InvalidArgumentException */ #[Override] diff --git a/src/console/src/Concerns/InteractsWithIO.php b/src/console/src/Concerns/InteractsWithIO.php index c6a45d30eb..d293efbcb5 100644 --- a/src/console/src/Concerns/InteractsWithIO.php +++ b/src/console/src/Concerns/InteractsWithIO.php @@ -35,7 +35,7 @@ trait InteractsWithIO protected int $verbosity = OutputInterface::VERBOSITY_NORMAL; /** - * The mapping between human readable verbosity levels and Symfony's OutputInterface. + * The mapping between human-readable verbosity levels and Symfony's OutputInterface. */ protected array $verbosityMap = [ 'v' => OutputInterface::VERBOSITY_VERBOSE, diff --git a/src/console/src/Scheduling/ManagesAttributes.php b/src/console/src/Scheduling/ManagesAttributes.php index 4a68e1b3fa..7da4bceba1 100644 --- a/src/console/src/Scheduling/ManagesAttributes.php +++ b/src/console/src/Scheduling/ManagesAttributes.php @@ -80,7 +80,7 @@ trait ManagesAttributes protected array $rejects = []; /** - * The human readable description of the event. + * The human-readable description of the event. */ public ?string $description = null; diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php index a2cee70856..9e013620a3 100644 --- a/src/database/src/Concerns/ManagesTransactions.php +++ b/src/database/src/Concerns/ManagesTransactions.php @@ -123,7 +123,7 @@ protected function handleTransactionException(Throwable $e, int $currentAttempt, $exception = new DeadlockException( $e->getMessage(), - is_int($e->getCode()) ? $e->getCode() : 0, + $e->getCode(), $e ); diff --git a/src/database/src/DatabaseServiceProvider.php b/src/database/src/DatabaseServiceProvider.php index c7121881b3..d664bb2aca 100644 --- a/src/database/src/DatabaseServiceProvider.php +++ b/src/database/src/DatabaseServiceProvider.php @@ -128,8 +128,8 @@ protected function registerConnectionServices(): void return $app->make('db')->connection(); }); - $this->app->singleton('db.schema', function () { - return new SchemaProxy; + $this->app->singleton('db.schema', function ($app) { + return new SchemaProxy($app); }); $this->app->singleton('db.transactions', function () { diff --git a/src/database/src/DeadlockException.php b/src/database/src/DeadlockException.php index 9321145734..9b5fb136b1 100644 --- a/src/database/src/DeadlockException.php +++ b/src/database/src/DeadlockException.php @@ -5,7 +5,22 @@ namespace Hypervel\Database; use PDOException; +use Throwable; class DeadlockException extends PDOException { + /** + * Create a new deadlock exception instance. + */ + public function __construct(string $message = '', int|string $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + + // Losing driver metadata makes nested concurrency failures unrecognizable to error detectors. + $this->code = $code; + + if ($previous instanceof PDOException) { + $this->errorInfo = $previous->errorInfo; + } + } } diff --git a/src/database/src/Eloquent/Casts/AsBinary.php b/src/database/src/Eloquent/Casts/AsBinary.php index 3b22c2defa..98a44e9cd8 100644 --- a/src/database/src/Eloquent/Casts/AsBinary.php +++ b/src/database/src/Eloquent/Casts/AsBinary.php @@ -15,12 +15,17 @@ class AsBinary implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array{string} $arguments + * + * @throws InvalidArgumentException */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { protected string $format; + /** + * Create a new binary cast instance. + */ public function __construct(protected array $arguments) { $this->format = $this->arguments[0] @@ -35,6 +40,9 @@ public function __construct(protected array $arguments) } } + /** + * Transform the attribute from the underlying model values. + */ public function get(mixed $model, string $key, mixed $value, array $attributes): ?string { $attribute = $attributes[$key] ?? null; @@ -50,6 +58,9 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): return BinaryCodec::decode($attribute, $this->format); } + /** + * Transform the attribute to its underlying model values. + */ public function set(mixed $model, string $key, mixed $value, array $attributes): array { return [$key => BinaryCodec::encode($value, $this->format)]; diff --git a/src/database/src/Migrations/DatabaseMigrationRepository.php b/src/database/src/Migrations/DatabaseMigrationRepository.php index 78d4f7b7f3..bbc29d4e66 100755 --- a/src/database/src/Migrations/DatabaseMigrationRepository.php +++ b/src/database/src/Migrations/DatabaseMigrationRepository.php @@ -26,6 +26,8 @@ public function __construct( /** * Get the completed migrations. + * + * @return string[] */ public function getRan(): array { @@ -37,6 +39,8 @@ public function getRan(): array /** * Get the list of migrations. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrations(int $steps): array { @@ -51,6 +55,8 @@ public function getMigrations(int $steps): array /** * Get the list of the migrations by batch number. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrationsByBatch(int $batch): array { @@ -63,6 +69,8 @@ public function getMigrationsByBatch(int $batch): array /** * Get the last migration batch. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getLast(): array { @@ -73,6 +81,8 @@ public function getLast(): array /** * Get the completed migrations with their batch numbers. + * + * @return array */ public function getMigrationBatches(): array { @@ -94,6 +104,8 @@ public function log(string $file, int $batch): void /** * Remove a migration from the log. + * + * @param object{id?: int, migration: string, batch?: int} $migration */ public function delete(object $migration): void { diff --git a/src/database/src/Migrations/MigrationCreator.php b/src/database/src/Migrations/MigrationCreator.php index 622c18d2d4..9574394dce 100644 --- a/src/database/src/Migrations/MigrationCreator.php +++ b/src/database/src/Migrations/MigrationCreator.php @@ -19,6 +19,8 @@ class MigrationCreator /** * The registered post create hooks. + * + * @var (Closure(?string, string): void)[] */ protected array $postCreate = []; @@ -169,6 +171,11 @@ protected function firePostCreateHooks(?string $table, string $path): void /** * Register a post migration create hook. + * + * Boot-only. Hooks persist on the migration creator for the worker + * lifetime and run for every subsequent migration creation. + * + * @param Closure(?string, string): void $callback */ public function afterCreate(Closure $callback): void { diff --git a/src/database/src/Migrations/MigrationRepositoryInterface.php b/src/database/src/Migrations/MigrationRepositoryInterface.php index 147941a14e..9de783476b 100755 --- a/src/database/src/Migrations/MigrationRepositoryInterface.php +++ b/src/database/src/Migrations/MigrationRepositoryInterface.php @@ -8,26 +8,36 @@ interface MigrationRepositoryInterface { /** * Get the completed migrations. + * + * @return string[] */ public function getRan(): array; /** * Get the list of migrations. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrations(int $steps): array; /** * Get the list of the migrations by batch. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrationsByBatch(int $batch): array; /** * Get the last migration batch. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getLast(): array; /** * Get the completed migrations with their batch numbers. + * + * @return array */ public function getMigrationBatches(): array; @@ -38,6 +48,8 @@ public function log(string $file, int $batch): void; /** * Remove a migration from the log. + * + * @param object{id?: int, migration: string, batch?: int} $migration */ public function delete(object $migration): void; diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index e402964267..99f28139f4 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -35,6 +35,8 @@ class Migrator { /** * The custom connection resolver callback. + * + * @var null|(Closure(Resolver, ?string): Connection) */ protected static ?Closure $connectionResolverCallback = null; @@ -263,6 +265,7 @@ public function rollback(array|string $paths = [], array $options = []): array * Get the migrations for a rollback operation. * * @param array $options + * @return object{id: int, migration: string, batch: int}[] */ protected function getMigrationsForRollback(array $options): array { @@ -799,6 +802,8 @@ public function resolveConnection(?string $connection): Connection * * Boot-only. The callback persists in a static property for the worker * lifetime and runs on every migration's connection resolution. + * + * @param Closure(Resolver, ?string): Connection $callback */ public static function resolveConnectionsUsing(Closure $callback): void { diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index ac2a58e3a0..12d9870cdc 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -352,7 +352,8 @@ public function getPdo(): PDO $this->latestReadWriteTypeRetrieved = 'write'; $pdo = $this->resolvePdo(); - return static::$sessionConfigurators === [] + // Transaction and cleanup failures can invalidate a PDO even without session configurators. + return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) ? $pdo : $this->synchronizeSession($pdo, read: false); } @@ -376,7 +377,7 @@ public function getReadPdo(): PDO $pdo = $this->resolveReadPdo(); - return static::$sessionConfigurators === [] + return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) ? $pdo : $this->synchronizeSession($pdo, read: true); } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index d911c48433..34224c8b75 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -1331,9 +1331,9 @@ public function whereNotNull(string|array|ExpressionContract $columns, string $b /** * Add a "where between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function whereBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and', bool $not = false): static + public function whereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and', bool $not = false): static { $type = 'between'; @@ -1360,9 +1360,9 @@ public function whereBetween(self|EloquentBuilder|Relation|ExpressionContract|st /** * Add a "where between" statement using columns to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function whereBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static + public function whereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static { $type = 'betweenColumns'; @@ -1381,9 +1381,9 @@ public function whereBetweenColumns(self|EloquentBuilder|Relation|ExpressionCont /** * Add an "or where between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function orWhereBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static + public function orWhereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { return $this->whereBetween($column, $values, 'or'); } @@ -1391,7 +1391,7 @@ public function orWhereBetween(self|EloquentBuilder|Relation|ExpressionContract| /** * Add an "or where between" statement using columns to the query. */ - public function orWhereBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static + public function orWhereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { return $this->whereBetweenColumns($column, $values, 'or'); } @@ -1399,9 +1399,9 @@ public function orWhereBetweenColumns(self|EloquentBuilder|Relation|ExpressionCo /** * Add a "where not between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function whereNotBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and'): static + public function whereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and'): static { return $this->whereBetween($column, $values, $boolean, true); } @@ -1409,7 +1409,7 @@ public function whereNotBetween(self|EloquentBuilder|Relation|ExpressionContract /** * Add a "where not between" statement using columns to the query. */ - public function whereNotBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and'): static + public function whereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and'): static { return $this->whereBetweenColumns($column, $values, $boolean, true); } @@ -1417,9 +1417,9 @@ public function whereNotBetweenColumns(self|EloquentBuilder|Relation|ExpressionC /** * Add an "or where not between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function orWhereNotBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static + public function orWhereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { return $this->whereNotBetween($column, $values, 'or'); } @@ -1427,7 +1427,7 @@ public function orWhereNotBetween(self|EloquentBuilder|Relation|ExpressionContra /** * Add an "or where not between" statement using columns to the query. */ - public function orWhereNotBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static + public function orWhereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { return $this->whereNotBetweenColumns($column, $values, 'or'); } diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index 9058ebdaa2..ba6a5f183e 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -91,6 +91,8 @@ class Blueprint /** * Create a new schema blueprint. + * + * @param null|(Closure(self): void) $callback */ public function __construct(Connection $connection, string $table, ?Closure $callback = null) { @@ -1647,6 +1649,8 @@ protected function addColumnDefinition(ColumnDefinition $definition): ColumnDefi /** * Add the columns from the callback after the given column. + * + * @param Closure(self): void $callback */ public function after(string $column, Closure $callback): void { diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 8f2d30eb69..dfda897661 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -17,6 +17,12 @@ class Builder { use Macroable; + protected const int DEFAULT_STRING_LENGTH = 255; + + protected const int DEFAULT_TIME_PRECISION = 0; + + protected const string DEFAULT_MORPH_KEY_TYPE = 'int'; + /** * The database connection instance. */ @@ -36,18 +42,22 @@ class Builder /** * The default string length for migrations. + * + * @var null|non-negative-int */ - public static ?int $defaultStringLength = 255; + public static ?int $defaultStringLength = self::DEFAULT_STRING_LENGTH; /** * The default time precision for migrations. */ - public static ?int $defaultTimePrecision = 0; + public static ?int $defaultTimePrecision = self::DEFAULT_TIME_PRECISION; /** * The default relationship morph key type. + * + * @var 'int'|'ulid'|'uuid' */ - public static string $defaultMorphKeyType = 'int'; + public static string $defaultMorphKeyType = self::DEFAULT_MORPH_KEY_TYPE; /** * Create a new database Schema manager. @@ -63,6 +73,8 @@ public function __construct(Connection $connection) * * Boot-only. The length persists in a static property for the worker * lifetime and applies to every Blueprint::string() across all coroutines. + * + * @param non-negative-int $length */ public static function defaultStringLength(int $length): void { @@ -97,17 +109,6 @@ public static function defaultMorphKeyType(string $type): void static::$defaultMorphKeyType = $type; } - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$defaultStringLength = 255; - static::$defaultTimePrecision = 0; - static::$defaultMorphKeyType = 'int'; - static::flushMacros(); - } - /** * Set the default morph key type for migrations to UUIDs. * @@ -607,6 +608,11 @@ public function disableForeignKeyConstraints(): bool /** * Disable foreign key constraints during the execution of a callback. + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn */ public function withoutForeignKeyConstraints(Closure $callback): mixed { @@ -782,6 +788,10 @@ public function getCurrentSchemaName(): ?string /** * Parse the given database object reference and extract the schema and table. + * + * @return array{null|string, string} + * + * @throws InvalidArgumentException */ public function parseSchemaAndTable(string $reference, bool|string|null $withDefaultSchema = null): array { @@ -822,4 +832,15 @@ public function blueprintResolver(Closure $resolver): void { $this->resolver = $resolver; } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$defaultStringLength = self::DEFAULT_STRING_LENGTH; + static::$defaultTimePrecision = self::DEFAULT_TIME_PRECISION; + static::$defaultMorphKeyType = self::DEFAULT_MORPH_KEY_TYPE; + static::flushMacros(); + } } diff --git a/src/database/src/Schema/ColumnDefinition.php b/src/database/src/Schema/ColumnDefinition.php index 40a9381dd9..a62b767957 100644 --- a/src/database/src/Schema/ColumnDefinition.php +++ b/src/database/src/Schema/ColumnDefinition.php @@ -23,9 +23,8 @@ * @method $this instant() Specify that algorithm=instant should be used for the column operation (MySQL) * @method $this index(bool|string $indexName = null) Add an index * @method $this invisible() Specify that the column should be invisible to "SELECT *" (MySQL) - * @method $this lock(string $value) Specify the DDL lock mode for the column operation (MySQL) + * @method $this lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the column operation (MySQL) * @method $this nullable(bool $value = true) Allow NULL values to be inserted into the column - * @method $this persisted() Mark the computed generated column as persistent (SQL Server) * @method $this primary(bool $value = true) Add a primary index * @method $this spatialIndex(bool|string $indexName = null) Add a spatial index * @method $this vectorIndex(bool|string $indexName = null) Add a vector index diff --git a/src/database/src/Schema/ForeignKeyDefinition.php b/src/database/src/Schema/ForeignKeyDefinition.php index 8299fe0609..47a255bb83 100644 --- a/src/database/src/Schema/ForeignKeyDefinition.php +++ b/src/database/src/Schema/ForeignKeyDefinition.php @@ -9,11 +9,11 @@ /** * @method ForeignKeyDefinition deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) * @method ForeignKeyDefinition initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) - * @method ForeignKeyDefinition lock(string $value) Specify the DDL lock mode for the foreign key operation (MySQL) + * @method ForeignKeyDefinition lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the foreign key operation (MySQL) * @method ForeignKeyDefinition on(string $table) Specify the referenced table * @method ForeignKeyDefinition onDelete(string $action) Add an ON DELETE action * @method ForeignKeyDefinition onUpdate(string $action) Add an ON UPDATE action - * @method ForeignKeyDefinition references(array|string $columns) Specify the referenced column(s) + * @method ForeignKeyDefinition references(string|string[] $columns) Specify the referenced column(s) */ class ForeignKeyDefinition extends Fluent { diff --git a/src/database/src/Schema/IndexDefinition.php b/src/database/src/Schema/IndexDefinition.php index 3f6b883fe4..b7b9bf7f56 100644 --- a/src/database/src/Schema/IndexDefinition.php +++ b/src/database/src/Schema/IndexDefinition.php @@ -12,9 +12,9 @@ * @method $this deferrable(bool $value = true) Specify that the unique index is deferrable (PostgreSQL) * @method $this initiallyImmediate(bool $value = true) Specify the default time to check the unique index constraint (PostgreSQL) * @method $this language(string $language) Specify a language for the full text index (PostgreSQL) - * @method $this lock(string $value) Specify the DDL lock mode for the index operation (MySQL) + * @method $this lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the index operation (MySQL) * @method $this nullsNotDistinct(bool $value = true) Specify that the null values should not be treated as distinct (PostgreSQL) - * @method $this online(bool $value = true) Specify that index creation should not lock the table (PostgreSQL/SqlServer) + * @method $this online(bool $value = true) Specify that index creation should not lock the table (PostgreSQL) */ class IndexDefinition extends Fluent { diff --git a/src/database/src/Schema/SchemaProxy.php b/src/database/src/Schema/SchemaProxy.php index 49627c1ffc..7e0833d7e4 100644 --- a/src/database/src/Schema/SchemaProxy.php +++ b/src/database/src/Schema/SchemaProxy.php @@ -4,13 +4,30 @@ namespace Hypervel\Database\Schema; -use Hypervel\Container\Container; +use Closure; +use Hypervel\Contracts\Container\Container; +use Hypervel\Database\Connection; /** * @mixin Builder */ class SchemaProxy { + /** + * @var null|(Closure(Connection, string, null|Closure): Blueprint) + */ + protected ?Closure $resolver = null; + + /** + * Create a new schema proxy. + */ + public function __construct(protected Container $app) + { + } + + /** + * Forward a schema operation to the current connection's builder. + */ public function __call(string $name, array $arguments): mixed { return $this->connection() @@ -24,9 +41,29 @@ public function __call(string $name, array $arguments): mixed */ public function connection(?string $name = null): Builder { - return Container::getInstance() + $builder = $this->app ->make('db') ->connection($name) ->getSchemaBuilder(); + + // Retain configuration without retaining a coroutine's pooled connection. + if ($this->resolver !== null) { + $builder->blueprintResolver($this->resolver); + } + + return $builder; + } + + /** + * Set the default Schema Blueprint resolver callback. + * + * Boot-only. The callback persists on the shared proxy for the worker + * lifetime and applies to every subsequent schema builder it creates. + * + * @param Closure(Connection, string, null|Closure): Blueprint $resolver + */ + public function blueprintResolver(Closure $resolver): void + { + $this->resolver = $resolver; } } diff --git a/src/docs/blade.md b/src/docs/blade.md index 6bbc3b6e1c..4429f63008 100644 --- a/src/docs/blade.md +++ b/src/docs/blade.md @@ -586,7 +586,7 @@ To include the first view that exists from a given array of views, you may use t @includeFirst(['custom.admin', 'admin'], ['status' => 'complete']) ``` -If you would like to include a view without inheriting any variables from the parent view, you may use the `@includeIsolated` directive. The included view will only have access to variables you explicitly pass: +If you would like to include a view without inheriting any variables from the parent view, you may use the `@includeIsolated` directive. Variables shared with all views remain available, and you may pass additional data as the second argument: ```blade @includeIsolated('view.name', ['user' => $user]) diff --git a/src/docs/helpers.md b/src/docs/helpers.md index 5b3e0fc2c6..753ccff0f2 100644 --- a/src/docs/helpers.md +++ b/src/docs/helpers.md @@ -452,7 +452,7 @@ $filtered = Arr::except($array, ['price']); #### `Arr::exceptValues()` {.collection-method} -The `Arr::exceptValues` method removes the specified values from an array: +The `Arr::exceptValues` method removes the specified values from an array, preserving the original keys: ```php use Hypervel\Support\Arr; @@ -461,7 +461,7 @@ $array = ['foo', 'bar', 'baz', 'qux']; $filtered = Arr::exceptValues($array, ['foo', 'baz']); -// ['bar', 'qux'] +// [1 => 'bar', 3 => 'qux'] ``` You may also pass `true` to the `strict` argument to use strict type comparisons when filtering: @@ -473,7 +473,7 @@ $array = [1, '1', 2, '2']; $filtered = Arr::exceptValues($array, [1, 2], strict: true); -// ['1', '2'] +// [1 => '1', 3 => '2'] ``` @@ -889,7 +889,7 @@ $slice = Arr::only($array, ['name', 'price']); #### `Arr::onlyValues()` {.collection-method} -The `Arr::onlyValues` method returns only the specified values from an array: +The `Arr::onlyValues` method returns only the specified values from an array, preserving the original keys: ```php use Hypervel\Support\Arr; @@ -898,7 +898,7 @@ $array = ['foo', 'bar', 'baz', 'qux']; $filtered = Arr::onlyValues($array, ['foo', 'baz']); -// ['foo', 'baz'] +// [0 => 'foo', 2 => 'baz'] ``` You may also pass `true` to the `strict` argument to use strict type comparisons when filtering: @@ -910,7 +910,7 @@ $array = [1, '1', 2, '2']; $filtered = Arr::onlyValues($array, [1, 2], strict: true); -// [1, 2] +// [0 => 1, 2 => 2] ``` diff --git a/src/docs/horizon.md b/src/docs/horizon.md index eb51a56d32..d3a0db2576 100644 --- a/src/docs/horizon.md +++ b/src/docs/horizon.md @@ -853,3 +853,11 @@ You may provide the `queue` option to delete jobs from a specific queue: ```shell php artisan horizon:clear --queue=emails ``` + +To clear a queue on a specific connection, pass the connection name to the command: + +```shell +php artisan horizon:clear redis --queue=emails +``` + +If multiple connections share the same Redis queue, clearing it removes all jobs from that queue. Horizon removes dashboard records for the selected connection; records for the other connections remain until they expire and are trimmed. diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 6d3ff8a855..1a5fa7f6ff 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -1405,11 +1405,7 @@ $table->string('name')->lock('none'); $table->index('email')->lock('shared'); ``` -If the requested lock mode is incompatible with the operation, MySQL will raise an error. The `lock` modifier may be combined with the `instant` modifier to further optimize schema changes: - -```php -$table->string('name')->instant()->lock('none'); -``` +If the requested lock mode is incompatible with the operation, MySQL will raise an error. When the `instant` modifier is used, MySQL permits only the `default` lock mode. ### Modifying Columns diff --git a/src/docs/queries.md b/src/docs/queries.md index d286f1b2ba..ac61025c9e 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -964,6 +964,22 @@ $users = DB::table('users') ->get(); ``` +You may also pass a query builder or closure as the first argument to compare a subquery's result to the given values. For example, the following query retrieves users whose most recent score is between 50 and 100: + +```php +use Hypervel\Database\Query\Builder; + +$users = DB::table('users') + ->whereBetween(function (Builder $query) { + $query->select('score') + ->from('scores') + ->whereColumn('scores.user_id', 'users.id') + ->orderByDesc('scores.created_at') + ->limit(1); + }, [50, 100]) + ->get(); +``` + **whereNotBetween / orWhereNotBetween** The `whereNotBetween` method verifies that a column's value lies outside of two values: @@ -992,6 +1008,8 @@ $patients = DB::table('patients') ->get(); ``` +Like `whereBetween`, these methods also accept a query builder or closure as the first argument to compare a subquery's result to the two column values. + **whereValueBetween / whereValueNotBetween / orWhereValueBetween / orWhereValueNotBetween** The `whereValueBetween` method verifies that a given value is between the values of two columns of the same type in the same table row: @@ -1451,6 +1469,8 @@ $report = DB::table('orders') ->get(); ``` +The `havingNotBetween` method excludes results within the given range. You may use `orHavingBetween` and `orHavingNotBetween` to join these conditions to the previous having clause using `or`. + You may pass multiple arguments to the `groupBy` method to group by multiple columns: ```php diff --git a/src/docs/queues.md b/src/docs/queues.md index d48e079021..3da121d80a 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -1655,9 +1655,9 @@ In addition to routing specific job classes, you may also pass an interface, tra Typically, you should call the `route` method from the `boot` method of a service provider: ```php +use App\Concerns\RequiresVideo; use App\Jobs\ProcessPodcast; use App\Jobs\ProcessVideo; -use App\Traits\RequiresVideo; use Hypervel\Support\Facades\Queue; /** @@ -1681,13 +1681,38 @@ You may also route multiple job classes at once by passing an array to the `rout ```php Queue::route([ ProcessPodcast::class => ['redis', 'podcasts'], // Connection and queue - ProcessVideo::class => [null, 'videos'], // Queue only (uses default connection) + ProcessVideo::class => 'videos', // Queue only (uses default connection) ]); ``` > [!NOTE] > Queue routing can still be overridden by the job on a per-job basis. +You may use the `forward` method to forward jobs from one queue to another queue and / or connection. This is useful when you need to change queue infrastructure without modifying individual jobs or dispatch locations. Register forwarding in a service provider's `boot` method: + +```php +Queue::forward('reports', 'reports.fifo', 'sqs'); +Queue::forward('payments', connection: 'sqs'); +Queue::forward('updates', 'notifications'); +``` + +You may also forward multiple queues at once by passing an array: + +```php +Queue::forward([ + 'reports' => 'reports.fifo', + 'emails' => 'emails.fifo', +], connection: 'sqs'); +``` + +An explicit connection configured on a job takes precedence over a forwarded connection. + +A forward scoped to a `failover` connection requires an explicit queue name; otherwise, each child connection uses its own default queue. + +After forwarding queues, update your worker queue lists to avoid listing multiple names that resolve to the same queue. Before forwarding a queue to a different name, drain its existing jobs. Workers using the forwarding configuration will consume the destination queue instead. + +When a forward specifies a connection, pass that connection to `queue:clear`; using another connection clears the source queue instead. Clearing a forwarded queue on the matching connection clears its destination, including jobs sent through other queue names that forward to the same destination. + ### Specifying Max Job Attempts / Timeout Values diff --git a/src/docs/validation.md b/src/docs/validation.md index 2545a4f328..4c8b31d2ba 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -998,6 +998,16 @@ The first argument passed to the `make` method is the data under validation. The After determining whether the request validation failed, you may use the `withErrors` method to flash the error messages to the session. When using this method, the `$errors` variable will automatically be shared with your views after redirection, allowing you to easily display them back to the user. The `withErrors` method accepts a validator, a `MessageBag`, or a PHP `array`. +#### Appending Rules + +Before running validation, you may use the `appendRules` method to add rules to an existing validator. The new rules are added to any rules already defined for each field: + +```php +$validator->appendRules([ + 'title' => 'min:5', +]); +``` + #### Stopping on First Validation Failure The `stopOnFirstFailure` method will inform the validator that it should stop validating all attributes once a single validation failure has occurred: diff --git a/src/docs/verification.md b/src/docs/verification.md index cd4107f6f9..6b7dd0605d 100644 --- a/src/docs/verification.md +++ b/src/docs/verification.md @@ -100,6 +100,12 @@ Before moving on, let's take a closer look at this route. First, you'll notice w Next, we can proceed directly to calling the `fulfill` method on the request. This method will call the `markEmailAsVerified` method on the authenticated user and dispatch the `Hypervel\Auth\Events\Verified` event. The `markEmailAsVerified` method is available to the default `App\Models\User` model via the `Hypervel\Foundation\Auth\User` base class. Once the user's email address has been verified, you may redirect them wherever you wish. +You may reset a user's email verification status using the `markEmailAsUnverified` method, for example, after the user changes their email address. This clears the stored verification timestamp and saves the user: + +```php +$user->markEmailAsUnverified(); +``` + By default, verification links expire after 60 minutes. You may change this duration using the `auth.verification.expire` configuration option. diff --git a/src/encryption/README.md b/src/encryption/README.md index c22d0c8247..9eef4c8375 100644 --- a/src/encryption/README.md +++ b/src/encryption/README.md @@ -1,4 +1,8 @@ Encryption for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/encryption) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/encryption) + +Documentation: https://hypervel.org/docs/encryption + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Encryption diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index ed28af727a..aedec9c734 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -965,10 +965,6 @@ protected function queueHandler(string $class, string $method, array $arguments) $connectionName = (string) enum_value($connectionName); } - $connection = $this->resolveQueue()->connection( - $connectionName ?? $this->resolveConnectionFromQueueRoute($listener) ?? null - ); - $queue = method_exists($listener, 'viaQueue') ? (isset($arguments[0]) ? $listener->viaQueue($arguments[0]) : $listener->viaQueue()) : $this->getAttributeValue($listener, QueueAttribute::class, 'queue'); @@ -978,13 +974,17 @@ protected function queueHandler(string $class, string $method, array $arguments) : $this->getAttributeValue($listener, Delay::class, 'delay'); if (is_null($queue)) { - $queue = $this->resolveQueueFromQueueRoute($listener) ?? null; + $queue = $this->resolveQueueFromQueueRoute($listener); } if ($queue instanceof UnitEnum) { $queue = (string) enum_value($queue); } + $connection = $this->resolveQueue()->connection( + $connectionName ?? $this->resolveConnectionFromQueueRoute($listener, $queue) + ); + if ($debounceFor !== null) { $debounce = (new DebounceLock($this->container->make(Cache::class)))->acquireForDispatch( $job, @@ -1124,6 +1124,14 @@ protected function resolveQueue(): QueueFactory return call_user_func($this->queueResolver); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): ContainerContract + { + return $this->container; + } + /** * Set the queue resolver implementation. * diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c2acd761a9..e7a4648495 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -3,7 +3,7 @@ Filesystem for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/filesystem) -Ported from: https://github.com/laravel/framework (illuminate/filesystem) +Documentation: https://hypervel.org/docs/filesystem ## Differences From Laravel @@ -18,3 +18,5 @@ Filesystem construction differs from Laravel in how it carries logical disk iden Hypervel registers signed file-serving routes for any configured disk whose `serve` option is exactly `true`, while Laravel limits these routes to local disks and accepts truthy values. Every served disk must use a unique URL or application boot will fail. Custom drivers that opt in must provide the filesystem response methods used by these routes. Hypervel also provides `ScopedFilesystemProxy` and `ScopedCloudFilesystemProxy` for prefixes resolved independently on every operation. The underlying disk may be fixed or resolved once per operation when its configuration varies with the current context. These decorators fail closed on empty prefixes and reject unmapped calls so request- or tenant-scoped boundaries cannot be bypassed. + +Ported from: https://github.com/laravel/framework (illuminate/filesystem) diff --git a/src/horizon/src/Console/ClearCommand.php b/src/horizon/src/Console/ClearCommand.php index e03ec1db1d..791181d262 100644 --- a/src/horizon/src/Console/ClearCommand.php +++ b/src/horizon/src/Console/ClearCommand.php @@ -6,8 +6,12 @@ use Hypervel\Console\Command; use Hypervel\Console\ConfirmableTrait; +use Hypervel\Contracts\Queue\ClearableQueue; use Hypervel\Horizon\Contracts\JobRepository; +use Hypervel\Horizon\RedisQueue; use Hypervel\Queue\QueueManager; +use Hypervel\Support\Str; +use ReflectionClass; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'horizon:clear')] @@ -44,13 +48,24 @@ public function handle(JobRepository $jobRepository, QueueManager $manager): ?in } $queue = $this->getQueue($connection); + $queueConnection = $manager->connection($connection); - if (method_exists($jobRepository, 'purge')) { - $jobRepository->purge($queue); + if (! $queueConnection instanceof ClearableQueue) { + $this->components->error('Clearing queues is not supported on [' . (new ReflectionClass($queueConnection))->getShortName() . ']'); + + return 1; + } + + if ($queueConnection instanceof RedisQueue) { + // Horizon records the forwarded destination; clear still needs the original + // queue name so the destination is not forwarded a second time. + $jobRepository->purge( + Str::replaceFirst('queues:', '', $queueConnection->getQueue($queue)), + $queueConnection->getConnectionName(), + ); } - /** @phpstan-ignore-next-line */ - $count = $manager->connection($connection)->clear($queue); + $count = $queueConnection->clear($queue); $this->components->info('Cleared ' . $count . ' jobs from the [' . $queue . '] queue.'); diff --git a/src/horizon/src/Contracts/JobRepository.php b/src/horizon/src/Contracts/JobRepository.php index d317570b7e..69c1338a15 100644 --- a/src/horizon/src/Contracts/JobRepository.php +++ b/src/horizon/src/Contracts/JobRepository.php @@ -155,4 +155,9 @@ public function storeRetryReference(string $id, string $retryId): void; * Delete a failed job by ID. */ public function deleteFailed(string $id): int; + + /** + * Delete pending and reserved jobs for a queue, optionally on one connection. + */ + public function purge(string $queue, ?string $connection = null): int; } diff --git a/src/horizon/src/LuaScripts.php b/src/horizon/src/LuaScripts.php index dbc47910ac..2cab139b20 100644 --- a/src/horizon/src/LuaScripts.php +++ b/src/horizon/src/LuaScripts.php @@ -43,12 +43,14 @@ public static function updateMetrics(): string * ARGV[1] - The prefix of the Horizon keys * ARGV[2] - The name of the queue to purge * ARGV[3] - The cursor position + * ARGV[4] - The optional connection name to purge */ public static function purge(): string { return <<<'LUA' local count = 0 local cursor = ARGV[3] + local connection = ARGV[4] -- Iterate over the recent jobs sorted set local scanner = redis.call('zscan', KEYS[1], cursor) @@ -57,11 +59,12 @@ public static function purge(): string for i = 1, #scanner[2], 2 do local jobid = scanner[2][i] local hashkey = ARGV[1] .. jobid - local job = redis.call('hmget', hashkey, 'status', 'queue') + local job = redis.call('hmget', hashkey, 'status', 'queue', 'connection') -- Delete the pending/reserved jobs, that match the queue - -- name, from the sorted sets as well as the job hash - if((job[1] == 'reserved' or job[1] == 'pending') and job[2] == ARGV[2]) then + -- and optional connection, from the sorted sets and job hash. + if((job[1] == 'reserved' or job[1] == 'pending') and job[2] == ARGV[2] + and (connection == nil or job[3] == connection)) then redis.call('zrem', KEYS[1], jobid) redis.call('zrem', KEYS[2], jobid) redis.call('del', hashkey) diff --git a/src/horizon/src/RedisQueue.php b/src/horizon/src/RedisQueue.php index bc3c498181..984a135053 100644 --- a/src/horizon/src/RedisQueue.php +++ b/src/horizon/src/RedisQueue.php @@ -172,6 +172,9 @@ public function pop(?string $queue = null, int $index = 0): ?Job /** * Migrate the delayed jobs that are ready to the regular queue. + * + * @param string $from the formatted Redis source key, already resolved through queue forwarding + * @param string $to the formatted Redis destination key, already resolved through queue forwarding */ #[Override] public function migrateExpiredJobs(string $from, string $to): array diff --git a/src/horizon/src/Repositories/RedisJobRepository.php b/src/horizon/src/Repositories/RedisJobRepository.php index 32a27f795d..cfbf0e74de 100644 --- a/src/horizon/src/Repositories/RedisJobRepository.php +++ b/src/horizon/src/Repositories/RedisJobRepository.php @@ -609,9 +609,9 @@ public function deleteFailed(string $id): int } /** - * Delete pending and reserved jobs for a queue. + * Delete pending and reserved jobs for a queue, optionally on one connection. */ - public function purge(string $queue): int + public function purge(string $queue, ?string $connection = null): int { $count = 0; $cursor = 0; @@ -625,6 +625,7 @@ public function purge(string $queue): int config()->string('horizon.prefix'), $queue, $cursor, + ...($connection === null ? [] : [$connection]), ); $count += $result[0]; diff --git a/src/http/src/Resources/CollectsResources.php b/src/http/src/Resources/CollectsResources.php index 217e7d09ed..7e452314c1 100644 --- a/src/http/src/Resources/CollectsResources.php +++ b/src/http/src/Resources/CollectsResources.php @@ -52,6 +52,8 @@ protected function collectResource(mixed $resource): mixed * Get the resource that this resource collects. * * @return null|class-string<\Hypervel\Http\Resources\Json\JsonResource> + * + * @throws LogicException */ protected function collects(): ?string { diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index 00427716ec..ba0953aa7d 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -6,6 +6,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Notifications\Dispatcher as DispatcherContract; @@ -71,6 +72,14 @@ public function sendNow(mixed $notifiables, mixed $notification, ?array $channel ))->sendNow($notifiables, $notification, $channels); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->container; + } + /** * Get a channel instance. */ diff --git a/src/notifications/src/NotificationSender.php b/src/notifications/src/NotificationSender.php index 78bf2384e9..4a8d5ee690 100644 --- a/src/notifications/src/NotificationSender.php +++ b/src/notifications/src/NotificationSender.php @@ -223,9 +223,7 @@ protected function queueNotification(mixed $notifiables, mixed $notification): v $notification->locale = $this->locale; } - $connection = $this->getAttributeValue($notification, Connection::class, 'connection') - ?? $this->manager->resolveConnectionFromQueueRoute($notification) - ?? null; + $connection = $this->getAttributeValue($notification, Connection::class, 'connection'); if (method_exists($notification, 'viaConnections')) { $connection = $notification->viaConnections()[$channel] ?? $connection; @@ -239,6 +237,8 @@ protected function queueNotification(mixed $notifiables, mixed $notification): v $queue = $notification->viaQueues()[$channel] ?? $queue; } + $connection ??= $this->manager->resolveConnectionFromQueueRoute($notification, $queue); + $delay = method_exists($notification, 'withDelay') ? ($notification->withDelay($notifiable, $channel) ?? null) : $this->getAttributeValue($notification, Delay::class, 'delay'); diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index bdde992b5d..42159fdd27 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -270,7 +270,7 @@ public function deleteMessage(string $queue, int|string $id): void */ public function getQueue(?string $queue): string { - return $queue === null || $queue === '' ? $this->default : $queue; + return $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** diff --git a/src/queue/src/Capsule/Manager.php b/src/queue/src/Capsule/Manager.php index e60d64ea6e..f2894e8593 100644 --- a/src/queue/src/Capsule/Manager.php +++ b/src/queue/src/Capsule/Manager.php @@ -7,9 +7,10 @@ use DateInterval; use DateTimeInterface; use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Queue\Queue; +use Hypervel\Queue\Concerns\RegistersQueueConnectors; use Hypervel\Queue\QueueManager; -use Hypervel\Queue\QueueServiceProvider; use Hypervel\Support\Traits\CapsuleManagerTrait; /** @@ -19,6 +20,7 @@ class Manager { use CapsuleManagerTrait; + use RegistersQueueConnectors; /** * The queue manager instance. @@ -60,13 +62,15 @@ protected function setupManager(): void */ protected function registerConnectors(): void { - // Capsule intentionally reuses the provider's connector registration logic with its - // standalone container; this works in practice and only differs from the provider's - // stricter application constructor type. - /** @phpstan-ignore-next-line */ - $provider = new QueueServiceProvider($this->container); + $this->registerDefaultConnectors($this->manager); + } - $provider->registerConnectors($this->manager); + /** + * Get the container used to resolve connector dependencies. + */ + protected function connectorContainer(): ContainerContract + { + return $this->container; } /** diff --git a/src/queue/src/Concerns/RegistersQueueConnectors.php b/src/queue/src/Concerns/RegistersQueueConnectors.php new file mode 100644 index 0000000000..457f8899e2 --- /dev/null +++ b/src/queue/src/Concerns/RegistersQueueConnectors.php @@ -0,0 +1,138 @@ +{"register{$connector}Connector"}($manager); + } + } + + /** + * Get the exception reporter for in-process queue connections. + */ + protected function exceptionReporter(): ?Closure + { + if (! $this->connectorContainer()->has(ExceptionHandler::class)) { + return null; + } + + return fn (Throwable $exception) => $this->connectorContainer()->make(ExceptionHandler::class)->report($exception); + } + + /** + * Register the Null queue connector. + */ + protected function registerNullConnector(QueueManager $manager): void + { + $manager->addConnector('null', fn () => new NullConnector); + } + + /** + * Register the Sync queue connector. + */ + protected function registerSyncConnector(QueueManager $manager): void + { + $manager->addConnector('sync', fn () => new SyncConnector); + } + + /** + * Register the Deferred queue connector. + */ + protected function registerDeferredConnector(QueueManager $manager): void + { + $manager->addConnector('deferred', fn () => new DeferredConnector($this->exceptionReporter())); + } + + /** + * Register the Background queue connector. + */ + protected function registerBackgroundConnector(QueueManager $manager): void + { + $manager->addConnector('background', fn () => new BackgroundConnector($this->exceptionReporter())); + } + + /** + * Register the Failover queue connector. + */ + protected function registerFailoverConnector(QueueManager $manager): void + { + $manager->addConnector('failover', fn () => new FailoverConnector( + $manager, + $this->connectorContainer()->make(EventDispatcher::class), + )); + } + + /** + * Register the database queue connector. + */ + protected function registerDatabaseConnector(QueueManager $manager): void + { + $manager->addConnector('database', function (): DatabaseConnector { + /** @var ConnectionResolverInterface $connections */ + $connections = $this->connectorContainer()->make('db'); + + return new DatabaseConnector($connections); + }); + } + + /** + * Register the Redis queue connector. + */ + protected function registerRedisConnector(QueueManager $manager): void + { + $manager->addConnector('redis', function (): RedisConnector { + /** @var RedisFactory $redis */ + $redis = $this->connectorContainer()->make('redis'); + + return new RedisConnector($redis); + }); + } + + /** + * Register the Beanstalkd queue connector. + */ + protected function registerBeanstalkdConnector(QueueManager $manager): void + { + $manager->addConnector('beanstalkd', fn () => new BeanstalkdConnector); + } + + /** + * Register the Amazon SQS queue connector. + */ + protected function registerSqsConnector(QueueManager $manager): void + { + $manager->addConnector('sqs', fn () => new SqsConnector); + } +} diff --git a/src/queue/src/Console/stubs/jobs.stub b/src/queue/src/Console/stubs/jobs.stub index bd85a99990..718b75b406 100644 --- a/src/queue/src/Console/stubs/jobs.stub +++ b/src/queue/src/Console/stubs/jobs.stub @@ -17,7 +17,7 @@ return new class extends Migration $table->id(); $table->string('queue')->index(); $table->jsonb('payload'); - $table->unsignedTinyInteger('attempts'); + $table->unsignedSmallInteger('attempts'); $table->unsignedInteger('reserved_at')->nullable(); $table->unsignedInteger('available_at'); $table->unsignedInteger('created_at'); diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 946c7bb6f7..dcedb06925 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -12,8 +12,11 @@ use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; +use Hypervel\Database\DetectsConcurrencyErrors; +use Hypervel\Database\DetectsLostConnections; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder; +use Hypervel\Database\QueryException; use Hypervel\Queue\Concerns\InsertsDatabaseRows; use Hypervel\Queue\Jobs\DatabaseJob; use Hypervel\Queue\Jobs\DatabaseJobRecord; @@ -25,6 +28,8 @@ class DatabaseQueue extends Queue implements QueueContract, ClearableQueue { + use DetectsConcurrencyErrors; + use DetectsLostConnections; use InsertsDatabaseRows; public const int DEFAULT_RETRY_AFTER = 60; @@ -477,13 +482,51 @@ protected function buildDatabaseRecord(?string $queue, string $payload, int $ava */ public function pop(?string $queue = null): ?Job { - $queue = $this->getQueue($queue); + // Keep the logical name on the job so reservation and release each forward it once. + $queue = $queue === null || $queue === '' ? $this->default : $queue; + $database = $this->getDatabase(); + $transactionLevel = $database->transactionLevel(); + /** @var null|DatabaseJobRecord $jobRecord */ + $jobRecord = null; - return $this->getDatabase()->transaction(function () use ($queue) { - if ($job = $this->getNextAvailableJob($queue)) { - return $this->marshalJob($queue, $job); + try { + return $database->transaction(function () use ($queue, &$jobRecord) { + if ($jobRecord = $this->getNextAvailableJob($queue)) { + $job = $this->marshalJob($queue, $jobRecord); + + // A commit or completion callback failure does not make this job invalid. + $jobRecord = null; + + return $job; + } + }); + } catch (QueryException $exception) { + // Recovery requires our transaction to have unwound. Transient database + // failures leave the job available for another reservation attempt. + // Non-query callback failures do not establish an invalid job record. + // Observers can run their own failing SQL, so match the reservation update. + if ($jobRecord !== null + && $database->transactionLevel() === $transactionLevel + && ! $this->causedByConcurrencyError($exception) + && ! $this->causedByLostConnection($exception) + && $this->causedByReservationQuery($exception, $database, $jobRecord)) { + try { + (new DatabaseJob( + $this->container, + $this, + $jobRecord, + $this->connectionName, + $queue + ))->fail($exception); + } catch (CanceledException $cancellation) { + throw $cancellation; + } catch (Throwable) { + // Preserve the original reservation failure if failing the job also fails. + } } - }); + + throw $exception; + } } /** @@ -568,6 +611,30 @@ protected function markJobAsReserved(DatabaseJobRecord $job): DatabaseJobRecord return $job; } + /** + * Determine whether the exception matches this job's reservation update. + * + * Override this alongside markJobAsReserved when changing its SQL or bindings. + */ + protected function causedByReservationQuery( + QueryException $exception, + ConnectionInterface $database, + DatabaseJobRecord $jobRecord + ): bool { + if ($exception->getConnectionName() !== $database->getName()) { + return false; + } + + $query = $database->table($this->table)->where('id', $jobRecord->id); + $values = ['reserved_at' => $jobRecord->reserved_at, 'attempts' => $jobRecord->attempts]; + $grammar = $query->getGrammar(); + + return $exception->getSql() === $grammar->compileUpdate($query, $values) + && $exception->getBindings() === $database->prepareBindings($query->cleanBindings( + $grammar->prepareBindingsForUpdate($query->getRawBindings(), $values) + )); + } + /** * Delete a reserved job from the queue. * @@ -611,7 +678,7 @@ public function clear(?string $queue): int */ public function getQueue(?string $queue): string { - return $queue === null || $queue === '' ? $this->default : $queue; + return $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 3f99aa415e..7ab12d878a 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -50,7 +50,7 @@ public function __construct( */ public function size(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->size($queue); + return $this->manager->connection($this->connections[0])->size($this->resolveForwardedQueue($queue)); } /** @@ -58,7 +58,7 @@ public function size(?string $queue = null): int */ public function pendingSize(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->pendingSize($queue); + return $this->manager->connection($this->connections[0])->pendingSize($this->resolveForwardedQueue($queue)); } /** @@ -66,7 +66,7 @@ public function pendingSize(?string $queue = null): int */ public function delayedSize(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->delayedSize($queue); + return $this->manager->connection($this->connections[0])->delayedSize($this->resolveForwardedQueue($queue)); } /** @@ -74,7 +74,7 @@ public function delayedSize(?string $queue = null): int */ public function reservedSize(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->reservedSize($queue); + return $this->manager->connection($this->connections[0])->reservedSize($this->resolveForwardedQueue($queue)); } /** @@ -116,7 +116,7 @@ public function totalReservedSize(): int public function pendingJobs(?string $queue = null): Collection { // Inspection remains an optional concrete capability, not part of the core Queue contract. - return $this->manager->connection($this->connections[0])->pendingJobs($queue); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->pendingJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound } /** @@ -124,7 +124,7 @@ public function pendingJobs(?string $queue = null): Collection */ public function delayedJobs(?string $queue = null): Collection { - return $this->manager->connection($this->connections[0])->delayedJobs($queue); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->delayedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound } /** @@ -132,7 +132,7 @@ public function delayedJobs(?string $queue = null): Collection */ public function reservedJobs(?string $queue = null): Collection { - return $this->manager->connection($this->connections[0])->reservedJobs($queue); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->reservedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound } /** @@ -166,7 +166,7 @@ public function creationTimeOfOldestPendingJob(?string $queue = null): ?int { return $this->manager ->connection($this->connections[0]) - ->creationTimeOfOldestPendingJob($queue); + ->creationTimeOfOldestPendingJob($this->resolveForwardedQueue($queue)); } /** @@ -174,6 +174,8 @@ public function creationTimeOfOldestPendingJob(?string $queue = null): ?int */ public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed { + $queue = $this->resolveForwardedQueue($queue); + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args(), $job); } @@ -182,6 +184,8 @@ public function push(object|string $job, mixed $data = '', ?string $queue = null */ public function pushRaw(string $payload, ?string $queue = null, array $options = []): mixed { + $queue = $this->resolveForwardedQueue($queue); + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args()); } @@ -190,6 +194,8 @@ public function pushRaw(string $payload, ?string $queue = null, array $options = */ public function later(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = '', ?string $queue = null): mixed { + $queue = $this->resolveForwardedQueue($queue); + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args(), $job); } @@ -198,6 +204,7 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ */ public function pop(?string $queue = null, int $index = 0): ?JobContract { + $queue = $this->resolveForwardedQueue($queue); $connection = $this->manager->connection($this->connections[0]); return $connection instanceof IndexAwareQueue @@ -205,6 +212,15 @@ public function pop(?string $queue = null, int $index = 0): ?JobContract : $connection->pop($queue); } + /** + * Resolve forwards owned by this failover connection. + */ + protected function resolveForwardedQueue(?string $queue): ?string + { + // Unscoped forwards belong to the storage driver; applying them here would forward twice. + return $queue === null ? null : $this->queueRoutes()->forwardedQueueForConnection($queue, $this->connectionName ?? null); + } + /** * Attempt the given method on all connections. * diff --git a/src/queue/src/Jobs/DatabaseJobRecord.php b/src/queue/src/Jobs/DatabaseJobRecord.php index 853aeb95a1..727f11f800 100644 --- a/src/queue/src/Jobs/DatabaseJobRecord.php +++ b/src/queue/src/Jobs/DatabaseJobRecord.php @@ -11,6 +11,7 @@ * @property int $id * @property string $payload * @property int $attempts + * @property null|int $reserved_at */ class DatabaseJobRecord { diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 0dbe8934c8..9f6b30a6cb 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -8,6 +8,7 @@ use DateInterval; use DateTimeInterface; use Hypervel\Bus\DispatchLockContext; +use Hypervel\Container\Container as GlobalContainer; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Encryption\Encrypter; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; @@ -31,6 +32,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Facades\Context; use Hypervel\Support\InteractsWithTime; +use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use Hypervel\Support\Str; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -42,6 +44,7 @@ abstract class Queue { use InteractsWithTime; use ReadsQueueAttributes; + use ResolvesQueueRoutes; /** * The IoC container instance. @@ -662,6 +665,22 @@ protected function raiseJobQueuedEvent(?string $queue, mixed $jobId, object|stri } } + /** + * Get the routed queue name for the given queue. + */ + protected function resolveQueue(string $queue): string + { + return $this->queueRoutes()->forwardedQueue($queue, $this->connectionName ?? null); + } + + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->container ?? GlobalContainer::getInstance(); + } + /** * Get the connection name for the queue. */ diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 2c0d8eb51d..5a48506e29 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -169,6 +169,27 @@ public function route(array|string $class, UnitEnum|string|null $queue = null, U $this->queueRoutes()->set($class, $queue, $connection); } + /** + * Forward the given queue to another queue and/or connection. + * + * Boot-only. Forwards persist on the singleton QueueRoutes registry for + * the worker lifetime and affect every subsequent dispatch and queue operation. + * + * @param array|string|UnitEnum $queue + */ + public function forward(array|string|UnitEnum $queue, UnitEnum|string|null $to = null, UnitEnum|string|null $connection = null): void + { + $this->queueRoutes()->forward($queue, $to, $connection); + } + + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->app; + } + /** * Pause a queue by its connection and name. */ diff --git a/src/queue/src/QueueRoutes.php b/src/queue/src/QueueRoutes.php index ad1a79ec29..78886b1054 100644 --- a/src/queue/src/QueueRoutes.php +++ b/src/queue/src/QueueRoutes.php @@ -4,12 +4,16 @@ namespace Hypervel\Queue; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; +use Hypervel\Support\Traits\ReadsClassAttributes; use UnitEnum; use function Hypervel\Support\enum_value; class QueueRoutes { + use ReadsClassAttributes; + /** * The mapping of class names to their default routes. * @@ -17,20 +21,46 @@ class QueueRoutes */ protected array $routes = []; + /** + * The queues that have been forwarded to another queue and/or connection. + * + * @var array + */ + protected array $forwards = []; + /** * Get the queue connection that a given queueable instance should be routed to. + * + * @param null|string|UnitEnum $queue the caller-selected queue, overriding the queueable's queue when resolving a forwarded connection */ - public function getConnection(object $queueable): ?string + public function getConnection(object $queueable, UnitEnum|string|null $queue = null): ?string { $route = $this->getRoute($queueable); - if (is_null($route)) { + if (is_array($route) && $route[0] !== null) { + return $route[0]; + } + + if (empty($this->forwards)) { return null; } - return is_string($route) - ? null - : $route[0]; + return $this->forwardedConnection( + $queue ?? $this->getAttributeValue($queueable, QueueAttribute::class, 'queue') + ?? (is_string($route) ? $route : ($route[1] ?? null)) + ); + } + + /** + * Get the connection the given queue has been forwarded to. + */ + protected function forwardedConnection(UnitEnum|string|null $queue): ?string + { + if (is_null($queue)) { + return null; + } + + return $this->forwards[enum_value($queue)][0] ?? null; } /** @@ -49,6 +79,32 @@ public function getQueue(object $queueable): ?string : $route[1]; } + /** + * Get the queue the given queue has been forwarded to. + */ + public function forwardedQueue(string $queue, ?string $connection = null): string + { + if (! isset($this->forwards[$queue])) { + return $queue; + } + + [$forwardConnection, $forwardQueue] = $this->forwards[$queue]; + + return is_null($forwardConnection) || $forwardConnection === $connection + ? $forwardQueue ?? $queue + : $queue; + } + + /** + * Apply only forwards explicitly scoped to the given connection. + */ + public function forwardedQueueForConnection(string $queue, ?string $connection): string + { + return isset($this->forwards[$queue][0]) + ? $this->forwardedQueue($queue, $connection) + : $queue; + } + /** * Get the route for a given queueable instance. * @@ -98,6 +154,26 @@ public function set(array|string $class, UnitEnum|string|null $queue = null, Uni } } + /** + * Register a forward for the given queue. + * + * Boot-only. Forwards persist on the singleton registry for the worker + * lifetime and affect every subsequent dispatch and queue operation. + * + * @param array|string|UnitEnum $queue + */ + public function forward(UnitEnum|array|string $queue, UnitEnum|string|null $to = null, UnitEnum|string|null $connection = null): void + { + $forwards = is_array($queue) ? $queue : [enum_value($queue) => $to]; + + foreach ($forwards as $from => $destination) { + $this->forwards[$from] = [ + $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection, + $destination instanceof UnitEnum ? (string) enum_value($destination) : $destination, + ]; + } + } + /** * Get all registered queue routes. * diff --git a/src/queue/src/QueueServiceProvider.php b/src/queue/src/QueueServiceProvider.php index 93cf70d6ae..e91ce62aba 100644 --- a/src/queue/src/QueueServiceProvider.php +++ b/src/queue/src/QueueServiceProvider.php @@ -4,21 +4,10 @@ namespace Hypervel\Queue; -use Closure; +use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Database\ModelIdentifier; use Hypervel\Contracts\Debug\ExceptionHandler; -use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; -use Hypervel\Contracts\Redis\Factory as RedisFactory; -use Hypervel\Database\ConnectionResolverInterface; -use Hypervel\Queue\Connectors\BackgroundConnector; -use Hypervel\Queue\Connectors\BeanstalkdConnector; -use Hypervel\Queue\Connectors\DatabaseConnector; -use Hypervel\Queue\Connectors\DeferredConnector; -use Hypervel\Queue\Connectors\FailoverConnector; -use Hypervel\Queue\Connectors\NullConnector; -use Hypervel\Queue\Connectors\RedisConnector; -use Hypervel\Queue\Connectors\SqsConnector; -use Hypervel\Queue\Connectors\SyncConnector; +use Hypervel\Queue\Concerns\RegistersQueueConnectors; use Hypervel\Queue\Console\BatchesTableCommand; use Hypervel\Queue\Console\ClearCommand; use Hypervel\Queue\Console\FailedTableCommand; @@ -43,10 +32,10 @@ use Hypervel\Support\ServiceProvider; use InvalidArgumentException; use Laravel\SerializableClosure\SerializableClosure; -use Throwable; class QueueServiceProvider extends ServiceProvider { + use RegistersQueueConnectors; use SerializesAndRestoresModelIdentifiers; /** @@ -160,109 +149,21 @@ protected function registerConnection(): void /** * Register the connectors on the queue manager. + * + * Boot-only. Connectors persist on the supplied manager for the worker + * lifetime and affect every subsequent connection it resolves. */ public function registerConnectors(QueueManager $manager): void { - foreach (['Null', 'Sync', 'Deferred', 'Background', 'Failover', 'Database', 'Redis', 'Beanstalkd', 'Sqs'] as $connector) { - $this->{"register{$connector}Connector"}($manager); - } - } - - /** - * Get the exception reporter for in-process queue connections. - */ - protected function exceptionReporter(): ?Closure - { - if (! $this->app->has(ExceptionHandler::class)) { - return null; - } - - return fn (Throwable $exception) => $this->app->make(ExceptionHandler::class)->report($exception); - } - - /** - * Register the Null queue connector. - */ - protected function registerNullConnector(QueueManager $manager): void - { - $manager->addConnector('null', fn () => new NullConnector); - } - - /** - * Register the Sync queue connector. - */ - protected function registerSyncConnector(QueueManager $manager): void - { - $manager->addConnector('sync', fn () => new SyncConnector); - } - - /** - * Register the Deferred queue connector. - */ - protected function registerDeferredConnector(QueueManager $manager): void - { - $manager->addConnector('deferred', fn () => new DeferredConnector($this->exceptionReporter())); - } - - /** - * Register the Background queue connector. - */ - protected function registerBackgroundConnector(QueueManager $manager): void - { - $manager->addConnector('background', fn () => new BackgroundConnector($this->exceptionReporter())); - } - - /** - * Register the Failover queue connector. - */ - protected function registerFailoverConnector(QueueManager $manager): void - { - $manager->addConnector('failover', fn () => new FailoverConnector( - $this->app->make('queue'), - $this->app->make(EventDispatcher::class), - )); - } - - /** - * Register the database queue connector. - */ - protected function registerDatabaseConnector(QueueManager $manager): void - { - $manager->addConnector('database', function (): DatabaseConnector { - /** @var ConnectionResolverInterface $connections */ - $connections = $this->app->make('db'); - - return new DatabaseConnector($connections); - }); - } - - /** - * Register the Redis queue connector. - */ - protected function registerRedisConnector(QueueManager $manager): void - { - $manager->addConnector('redis', function (): RedisConnector { - /** @var RedisFactory $redis */ - $redis = $this->app->make('redis'); - - return new RedisConnector($redis); - }); - } - - /** - * Register the Beanstalkd queue connector. - */ - protected function registerBeanstalkdConnector(QueueManager $manager): void - { - $manager->addConnector('beanstalkd', fn () => new BeanstalkdConnector); + $this->registerDefaultConnectors($manager); } /** - * Register the Amazon SQS queue connector. + * Get the container used to resolve connector dependencies. */ - protected function registerSqsConnector(QueueManager $manager): void + protected function connectorContainer(): Container { - $manager->addConnector('sqs', fn () => new SqsConnector); + return $this->app; } /** @@ -293,7 +194,7 @@ protected function registerListener(): void */ protected function registerRoutes(): void { - $this->app->singleton('queue.routes', fn () => new QueueRoutes); + $this->app->singleton('queue.routes', fn ($app) => $app->make(QueueRoutes::class)); } /** diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 5a0ed14146..b63fcef6b0 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -107,7 +107,18 @@ public function reservedSize(?string $queue = null): int public function totalSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->size($name)), + fn (): int => $this->allQueueNames()->sum(function (string $name): int { + // Discovered names identify storage; forwarding them again would count the destination twice. + $queue = $this->formatQueueRedisKey($name); + + return $this->getConnection()->eval( + LuaScripts::size(), + 3, + $queue, + $queue . ':delayed', + $queue . ':reserved', + ); + }), ); } @@ -117,7 +128,7 @@ public function totalSize(): int public function totalPendingSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->pendingSize($name)), + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->getConnection()->llen($this->formatQueueRedisKey($name))), ); } @@ -127,7 +138,7 @@ public function totalPendingSize(): int public function totalDelayedSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->delayedSize($name)), + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->getConnection()->zcard($this->formatQueueRedisKey($name) . ':delayed')), ); } @@ -137,7 +148,7 @@ public function totalDelayedSize(): int public function totalReservedSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->reservedSize($name)), + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->getConnection()->zcard($this->formatQueueRedisKey($name) . ':reserved')), ); } @@ -276,9 +287,10 @@ function (RedisConnection $connection) use ($name, $suffix): Collection { */ protected function inspectAllQueues(string $suffix = ''): Collection { + // Scan results already name physical queues, including backlogs left before forwarding was configured. return $this->getConnection()->withConnection( fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection) - ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix)), + ->flatMap(fn (string $name): Collection => $this->inspectJobsAtKey($connection, $this->formatQueueRedisKey($name), $name, $suffix)), transform: false, ); } @@ -290,7 +302,17 @@ protected function inspectAllQueues(string $suffix = ''): Collection */ protected function inspectJobsUsing(RedisConnection $connection, string $name, string $suffix): Collection { - $key = $this->getQueueRedisKey($name) . $suffix; + return $this->inspectJobsAtKey($connection, $this->getQueueRedisKey($name), $name, $suffix); + } + + /** + * Inspect a formatted storage key while retaining the requested queue identity. + * + * @return Collection + */ + protected function inspectJobsAtKey(RedisConnection $connection, string $key, string $name, string $suffix): Collection + { + $key .= $suffix; $payloads = $suffix === '' ? $connection->lrange($key, 0, -1) : $connection->zRange($key, 0, -1); @@ -687,23 +709,30 @@ protected function getRandomId(): string */ public function getQueue(?string $queue): string { - return 'queues:' . ($queue === null || $queue === '' ? $this->default : $queue); + return 'queues:' . $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** * Get the cluster-safe Redis key for the given queue. * - * Redis Cluster requires every key passed to a multi-key Lua script to live - * on the same hash slot. Queue payloads keep the logical queue name via - * getQueue(); only storage keys are hash-tagged here. + * Queue names are forwarded once before adding the storage prefix and hash tag. */ protected function getQueueRedisKey(?string $queue = null): string { - $queue = $queue === null || $queue === '' ? $this->default : $queue; + return $this->formatQueueRedisKey($this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue)); + } + /** + * Format a physical queue name as a cluster-safe Redis key. + * + * Redis Cluster requires every key passed to a multi-key Lua script to live + * on the same hash slot. Only storage keys are hash-tagged here. + */ + protected function formatQueueRedisKey(string $queue): string + { return $this->isClusterConnection() && ! RedisConnection::hasHashTag($queue) - ? $this->getQueue('{' . $queue . '}') - : $this->getQueue($queue); + ? 'queues:{' . $queue . '}' + : 'queues:' . $queue; } /** diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index a7c4392c9d..5bd041bd44 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -847,7 +847,7 @@ protected function ensureDelayIsSupported(DateInterval|DateTimeInterface|int|nul */ protected function resolveQueueName(?string $queue): string { - return $queue === null || $queue === '' ? $this->default : $queue; + return $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** diff --git a/src/support/src/BinaryCodec.php b/src/support/src/BinaryCodec.php index 54dadd08b7..77cb85cca5 100644 --- a/src/support/src/BinaryCodec.php +++ b/src/support/src/BinaryCodec.php @@ -33,6 +33,8 @@ public static function register(string $name, callable $encode, callable $decode /** * Encode a value to binary. + * + * @throws InvalidArgumentException */ public static function encode(Uuid|Ulid|string|null $value, string $format): ?string { @@ -63,6 +65,8 @@ public static function encode(Uuid|Ulid|string|null $value, string $format): ?st /** * Decode a binary value to string. + * + * @throws InvalidArgumentException */ public static function decode(?string $value, string $format): ?string { diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php index 084a445e4c..604d66cb20 100644 --- a/src/support/src/Facades/Broadcast.php +++ b/src/support/src/Facades/Broadcast.php @@ -26,7 +26,7 @@ * @method static \Pusher\Pusher pusher(array $config) * @method static void queue(mixed $event) * @method static \Hypervel\Broadcasting\BroadcastManager removePoolableDriver(string $driver) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void routes(array|null $attributes = null) * @method static \Hypervel\Broadcasting\BroadcastManager setApplication(\Hypervel\Contracts\Container\Container $app) diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php index 3a73874118..9348156407 100644 --- a/src/support/src/Facades/Bus.php +++ b/src/support/src/Facades/Bus.php @@ -23,7 +23,7 @@ * @method static bool hasCommandHandler(mixed $command) * @method static \Hypervel\Bus\Dispatcher map(array $map) * @method static \Hypervel\Bus\Dispatcher pipeThrough(array $pipes) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static \Hypervel\Bus\Dispatcher withDispatchingAfterResponses() * @method static \Hypervel\Bus\Dispatcher withoutDispatchingAfterResponses() diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php index 7bb4b5b56c..49cef8a8ab 100644 --- a/src/support/src/Facades/Event.php +++ b/src/support/src/Facades/Event.php @@ -28,7 +28,7 @@ * @method static void mixin(object $mixin, bool $replace = true) * @method static void observe(array|string $events, object|array|string $observer) * @method static void push(string $event, mixed $payload = []) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static \Hypervel\Events\Dispatcher setQueueResolver(callable $resolver) * @method static \Hypervel\Events\Dispatcher setTransactionManagerResolver(callable $resolver) diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index 73d648a3f0..66177451d9 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -25,7 +25,7 @@ * @method static \Hypervel\Notifications\ChannelManager locale(string $locale) * @method static void macro(string $name, callable|object $macro) * @method static void mixin(object $mixin, bool $replace = true) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void send(mixed $notifiables, mixed $notification) * @method static void sendNow(mixed $notifiables, mixed $notification, array|null $channels = null) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 1528e78d32..bba497e529 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -18,6 +18,7 @@ * @method static void exceptionOccurred(mixed $callback) * @method static void extend(string $driver, \Closure $resolver) * @method static void failing(mixed $callback) + * @method static void forward(array|string|\UnitEnum $queue, \UnitEnum|string|null $to = null, \UnitEnum|string|null $connection = null) * @method static \Hypervel\Contracts\Container\Container getApplication() * @method static string getDefaultDriver() * @method static string getName(string|null $connection = null) @@ -31,7 +32,7 @@ * @method static void pauseFor(string $connection, string $queue, \DateInterval|\DateTimeInterface|int $ttl) * @method static void purge(string|null $name = null) * @method static \Hypervel\Queue\QueueManager removePoolableDriver(string $driver) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void resume(string $connection, string $queue) * @method static void resumeAll() diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php index b7a36af7c5..e7e3cfef83 100644 --- a/src/support/src/Facades/Schema.php +++ b/src/support/src/Facades/Schema.php @@ -4,7 +4,6 @@ namespace Hypervel\Support\Facades; -use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Database\Schema\Builder; /** @@ -79,10 +78,7 @@ class Schema extends Facade */ public static function connection(?string $name = null): Builder { - /** @var ContainerContract $app */ - $app = static::$app; - - return $app->make('db')->connection($name)->getSchemaBuilder(); + return static::getFacadeRoot()->connection($name); } /** diff --git a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php index f24d3db821..4f6f8aac4d 100644 --- a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php +++ b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php @@ -5,16 +5,20 @@ namespace Hypervel\Support\Queue\Concerns; use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Queue\QueueRoutes; +use UnitEnum; trait ResolvesQueueRoutes { /** * Resolve the default connection name for a given queueable instance. + * + * @param null|string|UnitEnum $queue the caller-selected queue, overriding the queueable's queue when resolving a forwarded connection */ - public function resolveConnectionFromQueueRoute(object $queueable): ?string + public function resolveConnectionFromQueueRoute(object $queueable, UnitEnum|string|null $queue = null): ?string { - return $this->queueRoutes()->getConnection($queueable); + return $this->queueRoutes()->getConnection($queueable, $queue); } /** @@ -30,10 +34,19 @@ public function resolveQueueFromQueueRoute(object $queueable): ?string */ protected function queueRoutes(): QueueRoutes { - $container = Container::getInstance(); + $container = $this->queueRoutesContainer(); + // Standalone managers must share the container's registry even without a provider binding. return $container->bound('queue.routes') ? $container->make('queue.routes') - : new QueueRoutes; + : $container->make(QueueRoutes::class); + } + + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): ContainerContract + { + return Container::getInstance(); } } diff --git a/src/support/src/Traits/CapsuleManagerTrait.php b/src/support/src/Traits/CapsuleManagerTrait.php index 3eacd4ce42..349477dcac 100644 --- a/src/support/src/Traits/CapsuleManagerTrait.php +++ b/src/support/src/Traits/CapsuleManagerTrait.php @@ -4,8 +4,8 @@ namespace Hypervel\Support\Traits; +use Hypervel\Config\Repository; use Hypervel\Contracts\Container\Container; -use Hypervel\Support\Fluent; trait CapsuleManagerTrait { @@ -27,7 +27,7 @@ protected function setupContainer(Container $container): void $this->container = $container; if (! $this->container->bound('config')) { - $this->container->instance('config', new Fluent); + $this->container->instance('config', new Repository); } } diff --git a/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php b/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php index 7c9b8f9b7b..585f9fab8c 100644 --- a/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php +++ b/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php @@ -16,7 +16,7 @@ public function up(): void $table->id(); $table->string('queue')->index(); $table->longText('payload'); - $table->unsignedTinyInteger('attempts'); + $table->unsignedSmallInteger('attempts'); $table->unsignedInteger('reserved_at')->nullable(); $table->unsignedInteger('available_at'); $table->unsignedInteger('created_at'); diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 25b49633b6..8727d6619a 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -1671,10 +1671,12 @@ public function appendRules(array $rules): static /** * Parse the given rules and merge them into current rules. + * + * @internal */ public function addRules(array $rules): void { - // The primary purpose of this parser is to expand any "*" rules to the all + // The primary purpose of this parser is to expand any "*" rules to all // of the explicit rules needed for the given data. For example the rule // names.* would get expanded to names.0, names.1, etc. for this data. $response = (new ValidationRuleParser($this->data)) diff --git a/tests/Bus/BusDispatcherTest.php b/tests/Bus/BusDispatcherTest.php index 4588fbea8e..89a1ecaa6a 100644 --- a/tests/Bus/BusDispatcherTest.php +++ b/tests/Bus/BusDispatcherTest.php @@ -134,6 +134,52 @@ public function testCommandsAreDispatchedWithQueueRoute() Container::setInstance(null); } + public function testCommandsAreForwardedToConnectionByQueueName(): void + { + Container::setInstance($container = new Container); + $queueRoutes = new QueueRoutes; + $queueRoutes->forward('reports', 'processing', 'cloud'); + $container->instance('queue.routes', $queueRoutes); + + $mock = m::mock(Queue::class); + $mock->expects('push')->with(m::type(BusDispatcherQueueable::class), '', 'reports'); + + $usedConnection = false; + + $dispatcher = new Dispatcher($container, function (?string $connection) use ($mock, &$usedConnection): Queue { + $usedConnection = $connection; + + return $mock; + }); + + $dispatcher->dispatch((new BusDispatcherQueueable)->onQueue('reports')); + + $this->assertSame('cloud', $usedConnection); + } + + public function testExplicitConnectionWinsOverForwardedQueue(): void + { + Container::setInstance($container = new Container); + $queueRoutes = new QueueRoutes; + $queueRoutes->forward('reports', 'processing', 'cloud'); + $container->instance('queue.routes', $queueRoutes); + + $mock = m::mock(Queue::class); + $mock->expects('push')->with(m::type(BusDispatcherQueueable::class), '', 'reports'); + + $usedConnection = false; + + $dispatcher = new Dispatcher($container, function (?string $connection) use ($mock, &$usedConnection): Queue { + $usedConnection = $connection; + + return $mock; + }); + + $dispatcher->dispatch((new BusDispatcherQueueable)->onConnection('redis')->onQueue('reports')); + + $this->assertSame('redis', $usedConnection); + } + public function testDispatchNowShouldNeverQueue() { $container = new Container; diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 38a68e27d3..6dad8c060f 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -41,6 +41,7 @@ use PDO; use PDOException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -673,6 +674,45 @@ public function testTransactionMethodRetriesOnDeadlock() }, 3); } + #[TestWith(['40001'])] + #[TestWith(['55P03'])] + public function testTransactionRetriesNestedConcurrencyFailuresWithDriverMetadata(string $sqlState): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $previous = new PDOExceptionStub('Concurrent update could not complete.', $sqlState); + $previous->errorInfo = [$sqlState, 7, $previous->getMessage()]; + $failure = new QueryException('test', 'update records set value = 1', [], $previous); + $attempts = 0; + $wrappedExceptions = 0; + + $result = $connection->transaction(function () use ($connection, $failure, &$attempts, &$wrappedExceptions): string { + ++$attempts; + + try { + return $connection->transaction(static function () use ($failure, $attempts): string { + if ($attempts === 1) { + throw $failure; + } + + return 'success'; + }); + } catch (DeadlockException $exception) { + ++$wrappedExceptions; + + $this->assertSame($failure->getCode(), $exception->getCode()); + $this->assertSame($failure->errorInfo, $exception->errorInfo); + $this->assertSame($failure, $exception->getPrevious()); + + throw $exception; + } + }, 2); + + $this->assertSame('success', $result); + $this->assertSame(2, $attempts); + $this->assertSame(1, $wrappedExceptions); + $this->assertSame(0, $connection->transactionLevel()); + } + public function testTransactionMethodRollsbackAndThrows() { $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['inTransaction', 'beginTransaction', 'commit', 'rollBack'])->getMock(); diff --git a/tests/Database/DatabaseEloquentAsBinaryCastTest.php b/tests/Database/DatabaseEloquentAsBinaryCastTest.php index 369d145fa5..cfc3b774c4 100644 --- a/tests/Database/DatabaseEloquentAsBinaryCastTest.php +++ b/tests/Database/DatabaseEloquentAsBinaryCastTest.php @@ -122,6 +122,9 @@ class TestModel extends Model { protected array $guarded = []; + /** + * Get the attributes that should be cast. + */ protected function casts(): array { return [ diff --git a/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php b/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php index 69e65f7bc8..ac063cb5ae 100644 --- a/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php +++ b/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php @@ -5,9 +5,11 @@ namespace Hypervel\Tests\Database\DatabaseEloquentIntegrationWithTablePrefixTest; use Hypervel\Database\Capsule\Manager as DB; +use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model as Eloquent; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Database\Schema\Builder; use Hypervel\Testbench\TestCase; class DatabaseEloquentIntegrationWithTablePrefixTest extends TestCase @@ -34,7 +36,10 @@ protected function setUp(): void $this->createSchema(); } - protected function createSchema() + /** + * Create the database schema. + */ + protected function createSchema(): void { $this->schema('default')->create('users', function ($table) { $table->increments('id'); @@ -80,7 +85,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testBasicModelHydration() + public function testBasicModelHydration(): void { User::create(['email' => 'taylorotwell@gmail.com']); User::create(['email' => 'abigailotwell@gmail.com']); @@ -93,7 +98,7 @@ public function testBasicModelHydration() $this->assertCount(1, $models); } - public function testTablePrefixWithClonedConnection() + public function testTablePrefixWithClonedConnection(): void { $originalConnection = $this->connection(); $originalPrefix = $originalConnection->getTablePrefix(); @@ -116,7 +121,7 @@ public function testTablePrefixWithClonedConnection() $clonedConnection->getSchemaBuilder()->drop('test_table'); } - public function testQueryGrammarUsesCorrectPrefixAfterCloning() + public function testQueryGrammarUsesCorrectPrefixAfterCloning(): void { $originalConnection = $this->connection(); @@ -126,42 +131,35 @@ public function testQueryGrammarUsesCorrectPrefixAfterCloning() $selectSql = $clonedConnection->table('users')->toSql(); $this->assertStringContainsString('new_prefix_users', $selectSql); - $insertSql = $clonedConnection->table('users')->toSql(); - $this->assertStringContainsString('new_prefix_users', $insertSql); - - $updateSql = $clonedConnection->table('users')->where('id', 1)->toSql(); - $this->assertStringContainsString('new_prefix_users', $updateSql); + $queries = $clonedConnection->pretend(function (Connection $connection): void { + $connection->table('users')->insert(['email' => 'taylor@example.com']); + $connection->table('users')->where('id', 1)->update(['email' => 'abigail@example.com']); + $connection->table('users')->where('id', 1)->delete(); + }); - $deleteSql = $clonedConnection->table('users')->where('id', 1)->toSql(); - $this->assertStringContainsString('new_prefix_users', $deleteSql); + $this->assertSame([ + 'insert into "new_prefix_users" ("email") values (\'taylor@example.com\')', + 'update "new_prefix_users" set "email" = \'abigail@example.com\' where "id" = 1', + 'delete from "new_prefix_users" where "id" = 1', + ], array_column($queries, 'query')); $originalSql = $originalConnection->table('users')->toSql(); $this->assertStringContainsString('prefix_users', $originalSql); $this->assertStringNotContainsString('new_prefix_users', $originalSql); } - /** - * Helpers... - * @param mixed $connection - */ - /** * Get a database connection instance. - * - * @return \Illuminate\Database\Connection */ - protected function connection($connection = 'default') + protected function connection(string $connection = 'default'): Connection { return Eloquent::getConnectionResolver()->connection($connection); } /** * Get a schema builder instance. - * - * @param mixed $connection - * @return \Illuminate\Database\Schema\Builder */ - protected function schema($connection = 'default') + protected function schema(string $connection = 'default'): Builder { return $this->connection($connection)->getSchemaBuilder(); } diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 45292568be..54ba5e3193 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -3232,38 +3232,46 @@ public function testWhereForwardersAcceptQueryBuilderSubqueries(): void $this->assertSame([1, true, 5, true, 0, true, 3], $builder->getBindings()); } - public function testBetweenForwardersAcceptQueryBuilderSubqueries(): void + public function testBetweenForwardersAcceptQueryableSubqueries(): void { - $subquery = $this->getBuilder()->select('score')->from('scores')->where('active', true); - $builder = $this->getBuilder() - ->from('parents') - ->whereBetween($subquery, [1, 2]) - ->orWhereBetween($subquery, [3, 4]) - ->whereNotBetween($subquery, [5, 6]) - ->orWhereNotBetween($subquery, [7, 8]); + foreach ([ + $this->getBuilder()->select('score')->from('scores')->where('active', true), + static fn (Builder $query): Builder => $query->select('score')->from('scores')->where('active', true), + ] as $subquery) { + $builder = $this->getBuilder() + ->from('parents') + ->whereBetween($subquery, [1, 2]) + ->orWhereBetween($subquery, [3, 4]) + ->whereNotBetween($subquery, [5, 6]) + ->orWhereNotBetween($subquery, [7, 8]); - $this->assertSame( - 'select * from "parents" where (select "score" from "scores" where "active" = ?) between ? and ? or (select "score" from "scores" where "active" = ?) between ? and ? and (select "score" from "scores" where "active" = ?) not between ? and ? or (select "score" from "scores" where "active" = ?) not between ? and ?', - $builder->toSql() - ); - $this->assertSame([true, 1, 2, true, 3, 4, true, 5, 6, true, 7, 8], $builder->getBindings()); + $this->assertSame( + 'select * from "parents" where (select "score" from "scores" where "active" = ?) between ? and ? or (select "score" from "scores" where "active" = ?) between ? and ? and (select "score" from "scores" where "active" = ?) not between ? and ? or (select "score" from "scores" where "active" = ?) not between ? and ?', + $builder->toSql() + ); + $this->assertSame([true, 1, 2, true, 3, 4, true, 5, 6, true, 7, 8], $builder->getBindings()); + } } - public function testBetweenColumnsForwardersAcceptQueryBuilderSubqueries(): void + public function testBetweenColumnsForwardersAcceptQueryableSubqueries(): void { - $subquery = $this->getBuilder()->select('score')->from('scores')->where('active', true); - $builder = $this->getBuilder() - ->from('parents') - ->whereBetweenColumns($subquery, ['minimum', 'maximum']) - ->orWhereBetweenColumns($subquery, ['minimum', 'maximum']) - ->whereNotBetweenColumns($subquery, ['minimum', 'maximum']) - ->orWhereNotBetweenColumns($subquery, ['minimum', 'maximum']); + foreach ([ + $this->getBuilder()->select('score')->from('scores')->where('active', true), + static fn (Builder $query): Builder => $query->select('score')->from('scores')->where('active', true), + ] as $subquery) { + $builder = $this->getBuilder() + ->from('parents') + ->whereBetweenColumns($subquery, ['minimum', 'maximum']) + ->orWhereBetweenColumns($subquery, ['minimum', 'maximum']) + ->whereNotBetweenColumns($subquery, ['minimum', 'maximum']) + ->orWhereNotBetweenColumns($subquery, ['minimum', 'maximum']); - $this->assertSame( - 'select * from "parents" where (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" and (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum"', - $builder->toSql() - ); - $this->assertSame([true, true, true, true], $builder->getBindings()); + $this->assertSame( + 'select * from "parents" where (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" and (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum"', + $builder->toSql() + ); + $this->assertSame([true, true, true, true], $builder->getBindings()); + } } public function testOrderForwardersAcceptQueryableSubqueries(): void diff --git a/tests/Database/DatabaseSchemaProxyTest.php b/tests/Database/DatabaseSchemaProxyTest.php new file mode 100644 index 0000000000..a78bbeebf8 --- /dev/null +++ b/tests/Database/DatabaseSchemaProxyTest.php @@ -0,0 +1,112 @@ +make('config')->set('database.default', 'primary'); + $app->make('config')->set('database.connections', [ + 'primary' => ['driver' => 'sqlite', 'database' => ':memory:'], + 'secondary' => ['driver' => 'sqlite', 'database' => ':memory:'], + ]); + } + + public function testFacadeResolverAppliesToFreshBuildersOnTheSelectedConnection(): void + { + $calls = []; + + Schema::blueprintResolver(function (Connection $connection, string $table, ?Closure $callback) use (&$calls): Blueprint { + $calls[] = [$connection, $table, $callback]; + + return new Blueprint($connection, $table, $callback); + }); + + $create = static function (Blueprint $table): void { + $table->id(); + }; + $alter = static function (Blueprint $table): void { + $table->string('name'); + }; + + Schema::create('users', $create); + Schema::table('users', $alter); + Schema::connection('secondary')->create('users', $create); + DB::usingConnection('secondary', static function () use ($create): void { + Schema::create('posts', $create); + }); + + $this->assertSame([ + [DB::connection('primary'), 'users', null], + [DB::connection('primary'), 'users', $alter], + [DB::connection('secondary'), 'users', null], + [DB::connection('secondary'), 'posts', null], + ], $calls); + $this->assertTrue(Schema::hasColumn('users', 'name')); + $this->assertFalse(Schema::hasTable('posts')); + $this->assertTrue(Schema::connection('secondary')->hasTable('posts')); + } + + public function testFacadeUsesItsApplicationWhenTheGlobalContainerDiffers(): void + { + $primary = DB::connection('primary'); + $secondary = DB::connection('secondary'); + $container = Container::getInstance(); + + Container::setInstance(new Container); + + try { + $this->assertSame($secondary, Schema::connection('secondary')->getConnection()); + $this->assertSame($primary, Schema::getConnection()); + } finally { + Container::setInstance($container); + } + } + + public function testBuilderResolverOverridesRemainLocal(): void + { + $defaultTables = []; + $localTables = []; + + Schema::blueprintResolver(function (Connection $connection, string $table, ?Closure $callback) use (&$defaultTables): Blueprint { + $defaultTables[] = $table; + + return new Blueprint($connection, $table, $callback); + }); + + $local = Schema::connection(); + $other = Schema::connection(); + + $local->blueprintResolver(function (Connection $connection, string $table, ?Closure $callback) use (&$localTables): Blueprint { + $localTables[] = $table; + + return new Blueprint($connection, $table, $callback); + }); + + $create = static function (Blueprint $table): void { + $table->id(); + }; + + $local->create('local_users', $create); + $other->create('other_users', $create); + Schema::create('default_users', $create); + + $this->assertSame(['local_users'], $localTables); + $this->assertSame(['other_users', 'default_users'], $defaultTables); + } +} diff --git a/tests/Database/DatabaseSessionConfiguratorTest.php b/tests/Database/DatabaseSessionConfiguratorTest.php index 32ba4d335e..2338d2b035 100644 --- a/tests/Database/DatabaseSessionConfiguratorTest.php +++ b/tests/Database/DatabaseSessionConfiguratorTest.php @@ -17,6 +17,7 @@ use Hypervel\Tests\TestCase; use PDO; use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Throwable; @@ -335,10 +336,15 @@ public function testReentrantConfigurationFailsClosedAcrossWrappersSharingAPdo() } } - public function testUnknownWriteSessionIsReplacedOnceAndTheReplacementIsConfigured(): void + #[DataProvider('sessionConfigurationProvider')] + public function testUnknownWriteSessionIsReplacedOnce(bool $configureSession): void { $configurator = $this->configurator(); - PdoConnection::configureSessionUsing($configurator); + + if ($configureSession) { + PdoConnection::configureSessionUsing($configurator); + } + $oldPdo = $this->pdo(); $newPdo = $this->pdo(); $connection = $this->connection($oldPdo); @@ -352,14 +358,19 @@ public function testUnknownWriteSessionIsReplacedOnceAndTheReplacementIsConfigur $this->assertSame($newPdo, $connection->getPdo()); $this->assertSame(1, $reconnects); - $this->assertSame(2, $configurator->applyCalls); + $this->assertSame($configureSession ? 2 : 0, $configurator->applyCalls); $this->assertFalse(TestSessionConnection::sessionStateIsUnknownForTest($newPdo)); } - public function testUnknownReadSessionRecoveryKeepsTheReadRoute(): void + #[DataProvider('sessionConfigurationProvider')] + public function testUnknownReadSessionRecoveryKeepsTheReadRoute(bool $configureSession): void { $configurator = $this->configurator(); - PdoConnection::configureSessionUsing($configurator); + + if ($configureSession) { + PdoConnection::configureSessionUsing($configurator); + } + $writePdo = $this->pdo(); $oldReadPdo = $this->pdo(); $newReadPdo = $this->pdo(); @@ -373,7 +384,7 @@ public function testUnknownReadSessionRecoveryKeepsTheReadRoute(): void $this->assertSame($newReadPdo, $connection->getReadPdo()); $this->assertSame($writePdo, $connection->getRawPdo()); - $this->assertSame(2, $configurator->applyCalls); + $this->assertSame($configureSession ? 2 : 0, $configurator->applyCalls); } public function testUnknownReadFallbackRecoveryUsesTheReplacementWritePdo(): void @@ -440,9 +451,13 @@ public function testReentrantReconnectorCannotRecursivelyReplaceAnUnknownSession } } - public function testUnknownSessionInsideTransactionFailsWithoutReconnect(): void + #[DataProvider('sessionConfigurationProvider')] + public function testUnknownSessionInsideTransactionFailsWithoutReconnect(bool $configureSession): void { - PdoConnection::configureSessionUsing($this->configurator()); + if ($configureSession) { + PdoConnection::configureSessionUsing($this->configurator()); + } + $pdo = $this->pdo(); $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -464,6 +479,17 @@ public function testUnknownSessionInsideTransactionFailsWithoutReconnect(): void $this->assertSame(0, $reconnects); } + /** + * Provide session configurator registration states. + */ + public static function sessionConfigurationProvider(): array + { + return [ + 'configured' => [true], + 'unconfigured' => [false], + ]; + } + public function testUnknownSessionWithoutAReconnectorPreservesTheExistingFailure(): void { PdoConnection::configureSessionUsing($this->configurator()); diff --git a/tests/Encryption/EncrypterTest.php b/tests/Encryption/EncrypterTest.php index 71c4d8ecef..6dbcc16dcb 100644 --- a/tests/Encryption/EncrypterTest.php +++ b/tests/Encryption/EncrypterTest.php @@ -13,7 +13,7 @@ class EncrypterTest extends TestCase { - public function testEncryption() + public function testEncryption(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encrypt('foo'); @@ -33,7 +33,7 @@ public function testEncryption() $this->assertSame($data, $e->decrypt($encryptedArray)); } - public function testRawStringEncryption() + public function testRawStringEncryption(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encryptString('foo'); @@ -41,7 +41,7 @@ public function testRawStringEncryption() $this->assertSame('foo', $e->decryptString($encrypted)); } - public function testRawStringEncryptionWithPreviousKeys() + public function testRawStringEncryptionWithPreviousKeys(): void { $previous = new Encrypter(str_repeat('b', 16)); $previousValue = $previous->encryptString('foo'); @@ -53,7 +53,7 @@ public function testRawStringEncryptionWithPreviousKeys() $this->assertSame('foo', $decrypted); } - public function testItValidatesMacOnPerKeyBasis() + public function testItValidatesMacOnPerKeyBasis(): void { // Payload created with (key: str_repeat('b', 16)) but will // "successfully" decrypt with (key: str_repeat('a', 16)), however it @@ -82,7 +82,7 @@ public function testItValidatesEveryMacBeforeDecryptingWithTheFirstValidKey(): v ); } - public function testEncryptionUsingBase64EncodedKey() + public function testEncryptionUsingBase64EncodedKey(): void { $e = new Encrypter(random_bytes(16)); $encrypted = $e->encrypt('foo'); @@ -90,7 +90,7 @@ public function testEncryptionUsingBase64EncodedKey() $this->assertSame('foo', $e->decrypt($encrypted)); } - public function testEncryptedLengthIsFixed() + public function testEncryptedLengthIsFixed(): void { $e = new Encrypter(str_repeat('a', 16)); $lengths = []; @@ -100,7 +100,7 @@ public function testEncryptedLengthIsFixed() $this->assertSame(min($lengths), max($lengths)); } - public function testWithCustomCipher() + public function testWithCustomCipher(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('bar'); @@ -113,7 +113,7 @@ public function testWithCustomCipher() $this->assertSame('foo', $e->decrypt($encrypted)); } - public function testCipherNamesCanBeMixedCase() + public function testCipherNamesCanBeMixedCase(): void { $upper = new Encrypter(str_repeat('b', 16), 'AES-128-GCM'); $encrypted = $upper->encrypt('bar'); @@ -126,7 +126,7 @@ public function testCipherNamesCanBeMixedCase() $this->assertSame('bar', $mixed->decrypt($encrypted)); } - public function testThatAnAeadCipherIncludesTag() + public function testThatAnAeadCipherIncludesTag(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('foo'); @@ -136,7 +136,7 @@ public function testThatAnAeadCipherIncludesTag() $this->assertNotEmpty($data->tag); } - public function testThatAnAeadTagMustBeProvidedInFullLength() + public function testThatAnAeadTagMustBeProvidedInFullLength(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('foo'); @@ -174,7 +174,7 @@ public function testThatAnAeadTagMustNotBeEmpty(): void $encrypter->decrypt(base64_encode(json_encode($payload))); } - public function testThatAnAeadTagCantBeModified() + public function testThatAnAeadTagCantBeModified(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('foo'); @@ -188,7 +188,7 @@ public function testThatAnAeadTagCantBeModified() $e->decrypt($encrypted); } - public function testThatANonAeadCipherIncludesMac() + public function testThatANonAeadCipherIncludesMac(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-CBC'); $encrypted = $e->encrypt('foo'); @@ -198,7 +198,7 @@ public function testThatANonAeadCipherIncludesMac() $this->assertNotEmpty($data->mac); } - public function testDoNoAllowLongerKey() + public function testDoNoAllowLongerKey(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -206,7 +206,7 @@ public function testDoNoAllowLongerKey() new Encrypter(str_repeat('z', 32)); } - public function testWithBadKeyLength() + public function testWithBadKeyLength(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -214,7 +214,7 @@ public function testWithBadKeyLength() new Encrypter(str_repeat('a', 5)); } - public function testWithBadKeyLengthAlternativeCipher() + public function testWithBadKeyLengthAlternativeCipher(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -222,7 +222,7 @@ public function testWithBadKeyLengthAlternativeCipher() new Encrypter(str_repeat('a', 16), 'AES-256-GCM'); } - public function testWithUnsupportedCipher() + public function testWithUnsupportedCipher(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -230,7 +230,7 @@ public function testWithUnsupportedCipher() new Encrypter(str_repeat('c', 16), 'AES-256-CFB8'); } - public function testExceptionThrownWhenPayloadIsInvalid() + public function testExceptionThrownWhenPayloadIsInvalid(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The payload is invalid.'); @@ -241,7 +241,7 @@ public function testExceptionThrownWhenPayloadIsInvalid() $e->decrypt($payload); } - public function testDecryptionExceptionIsThrownWhenUnexpectedTagIsAdded() + public function testDecryptionExceptionIsThrownWhenUnexpectedTagIsAdded(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('Unable to use tag because the cipher algorithm does not support AEAD.'); @@ -253,7 +253,7 @@ public function testDecryptionExceptionIsThrownWhenUnexpectedTagIsAdded() $e->decrypt(base64_encode(json_encode($decodedPayload))); } - public function testExceptionThrownWithDifferentKey() + public function testExceptionThrownWithDifferentKey(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The MAC is invalid.'); @@ -263,7 +263,7 @@ public function testExceptionThrownWithDifferentKey() $b->decrypt($a->encrypt('baz')); } - public function testExceptionThrownWhenIvIsTooLong() + public function testExceptionThrownWhenIvIsTooLong(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The payload is invalid.'); @@ -277,7 +277,7 @@ public function testExceptionThrownWhenIvIsTooLong() $e->decrypt($modified_payload); } - public function testSupportedMethodAcceptsAnyCasing() + public function testSupportedMethodAcceptsAnyCasing(): void { $key = str_repeat('a', 16); @@ -287,7 +287,7 @@ public function testSupportedMethodAcceptsAnyCasing() } #[DataProvider('provideTamperedData')] - public function testTamperedPayloadWillGetRejected($payload) + public function testTamperedPayloadWillGetRejected(array $payload): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The payload is invalid.'); @@ -296,7 +296,10 @@ public function testTamperedPayloadWillGetRejected($payload) $enc->decrypt(base64_encode(json_encode($payload))); } - public static function provideTamperedData() + /** + * Provide tampered encrypted payloads. + */ + public static function provideTamperedData(): array { $validIv = base64_encode(str_repeat('.', 16)); @@ -312,7 +315,7 @@ public static function provideTamperedData() ]; } - public function testEncryptedReturnsTrueForEncryptedValue() + public function testEncryptedReturnsTrueForEncryptedValue(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encrypt('foo'); @@ -320,7 +323,7 @@ public function testEncryptedReturnsTrueForEncryptedValue() $this->assertTrue(Encrypter::appearsEncrypted($encrypted)); } - public function testEncryptedReturnsTrueForEncryptedArray() + public function testEncryptedReturnsTrueForEncryptedArray(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encrypt(['foo' => 'bar']); @@ -328,14 +331,14 @@ public function testEncryptedReturnsTrueForEncryptedArray() $this->assertTrue(Encrypter::appearsEncrypted($encrypted)); } - public function testEncryptedReturnsFalseForPlainText() + public function testEncryptedReturnsFalseForPlainText(): void { $this->assertFalse(Encrypter::appearsEncrypted('foo')); $this->assertFalse(Encrypter::appearsEncrypted('APP_NAME=Hypervel')); $this->assertFalse(Encrypter::appearsEncrypted("APP_NAME=Hypervel\nAPP_ENV=local")); } - public function testEncryptedReturnsFalseForNonString() + public function testEncryptedReturnsFalseForNonString(): void { $this->assertFalse(Encrypter::appearsEncrypted(123)); $this->assertFalse(Encrypter::appearsEncrypted(['foo' => 'bar'])); diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php index c1391e13ea..9efcb75df2 100644 --- a/tests/Events/QueuedEventsTest.php +++ b/tests/Events/QueuedEventsTest.php @@ -231,7 +231,7 @@ public function testQueueIsSetByGetConnectionDynamically() ]); } - public function testQueueIsSetUsingQueueRoutes() + public function testQueueIsSetUsingQueueRoutes(): void { $container = new Container; $d = new Dispatcher($container); @@ -240,18 +240,58 @@ public function testQueueIsSetUsingQueueRoutes() $queueRoutes->set(TestDispatcherQueueRoutes::class, 'event-queue', 'event-connection'); $container->instance('queue.routes', $queueRoutes); - $fakeQueue = new QueueFake($container); + $factory = m::mock(QueueFactory::class); + $queue = m::mock(Queue::class); + + $factory->shouldReceive('connection')->once()->with('event-connection')->andReturn($queue); + $queue->shouldReceive('pushOn')->once()->with('event-queue', m::type(CallQueuedListener::class)); Container::setInstance($container); - $d->setQueueResolver(function () use ($fakeQueue) { - return $fakeQueue; + $d->setQueueResolver(function () use ($factory): QueueFactory { + return $factory; }); $d->listen('some.event', TestDispatcherQueueRoutes::class . '@handle'); $d->dispatch('some.event', ['foo', 'bar']); + } + + public function testConnectionIsSetUsingForwardedQueue(): void + { + $container = new Container; + $d = new Dispatcher($container); + + $queueRoutes = new QueueRoutes; + $queueRoutes->forward('reports', 'processing', 'cloud'); + $container->instance('queue.routes', $queueRoutes); + + $factory = m::mock(QueueFactory::class); + $queue = m::mock(Queue::class); + $factory->shouldReceive('connection')->once()->with('cloud')->andReturn($queue); + $queue->shouldReceive('pushOn')->once()->with('reports', m::type(CallQueuedListener::class)); - $fakeQueue->connection('event-connection')->assertPushedOn('event-queue', CallQueuedListener::class); + Container::setInstance($container); + $d->setQueueResolver(fn (): QueueFactory => $factory); + $d->listen('some.event', TestDispatcherForwardedQueue::class . '@handle'); + $d->dispatch('some.event', ['foo', 'bar']); + } + + public function testForwardedConnectionUsesTheDynamicallySelectedQueue(): void + { + Container::setInstance($container = new Container); + $dispatcher = new Dispatcher($container); + $routes = new QueueRoutes; + $routes->forward('my_queue', 'unused', 'wrong-connection'); + $routes->forward('some_other_queue', 'processing', 'cloud'); + $container->instance('queue.routes', $routes); + $factory = m::mock(QueueFactory::class); + $queue = m::mock(Queue::class); + $factory->shouldReceive('connection')->once()->with('cloud')->andReturn($queue); + $queue->shouldReceive('pushOn')->once()->with('some_other_queue', m::type(CallQueuedListener::class)); + + $dispatcher->setQueueResolver(fn (): QueueFactory => $factory); + $dispatcher->listen('some.event', TestDispatcherGetQueue::class . '@handle'); + $dispatcher->dispatch('some.event', ['foo', 'bar']); } public function testDelayIsSetByWithDelayDynamically() @@ -1201,6 +1241,18 @@ public function handle() } } +class TestDispatcherForwardedQueue implements ShouldQueue +{ + public string $queue = 'reports'; + + /** + * Handle the queued event. + */ + public function handle(): void + { + } +} + class TestDispatcherShouldBeUnique implements ShouldQueue, ShouldBeUnique { public string $uniqueId = 'unique-listener-id'; diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 027c3fea04..0953bf7fc3 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -42,10 +42,12 @@ use Hypervel\Support\Facades\Broadcast; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Pusher\Pusher; use RuntimeException; @@ -120,13 +122,57 @@ public function testQueuedOrdinaryEventIsClonedOnce(): void public function testEventsCanBeBroadcastUsingQueueRoutes(): void { Bus::fake(); - Queue::fake(); + // QueueFake ignores connection names, so verify selection separately from its push assertions. + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('broadcast-connection')->andReturnSelf(); + Queue::swap($queue); Queue::route(TestEvent::class, 'broadcast-queue', 'broadcast-connection'); Broadcast::queue(new TestEvent); Bus::assertNotDispatched(BroadcastEvent::class); - Queue::connection('broadcast-connection')->assertPushedOn('broadcast-queue', BroadcastEvent::class); + Queue::assertPushedOn('broadcast-queue', BroadcastEvent::class); + } + + public function testEventsCanBeBroadcastWhenForwardingQueue(): void + { + Bus::fake(); + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('broadcast-connection')->andReturnSelf(); + Queue::swap($queue); + + Queue::forward('broadcast-queue', 'events', 'broadcast-connection'); + + Broadcast::queue(new TestForwardedEvent); + Bus::assertNotDispatched(BroadcastEvent::class); + Queue::assertPushedOn('broadcast-queue', BroadcastEvent::class); + } + + #[TestWith([null, 'cloud'])] + #[TestWith(['explicit', 'explicit'])] + public function testForwardedConnectionUsesTheBroadcastQueue(?string $connection, string $expectedConnection): void + { + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with($expectedConnection)->andReturnSelf(); + Queue::swap($queue); + Queue::forward('broadcast-queue', 'unused', 'wrong-connection'); + Queue::forward('updates', 'events', 'cloud'); + $event = new class extends TestForwardedEvent { + public ?string $connection = null; + + /** + * Select the queue used to broadcast this event. + */ + public function broadcastQueue(): string + { + return 'updates'; + } + }; + $event->connection = $connection; + + Broadcast::queue($event); + + Queue::assertPushedOn('updates', BroadcastEvent::class); } public function testEventsCanBeRescued(): void @@ -824,6 +870,21 @@ public function broadcastOn(): array } } +class TestForwardedEvent implements ShouldBroadcast +{ + public string $queue = 'broadcast-queue'; + + /** + * Get the channels the event should broadcast on. + * + * @return Channel[]|string[] + */ + public function broadcastOn(): array + { + return []; + } +} + class TestEventNow implements ShouldBroadcastNow { /** diff --git a/tests/Integration/Console/EnvironmentDecryptCommandTest.php b/tests/Integration/Console/EnvironmentDecryptCommandTest.php index 34a02f6247..aaaa719ab1 100644 --- a/tests/Integration/Console/EnvironmentDecryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentDecryptCommandTest.php @@ -107,7 +107,7 @@ public function testItGeneratesTheEnvironmentFileWithGeneratedKey(): void ->once() ->andReturn( (new Encrypter($key = Encrypter::generateKey('AES-256-CBC'), 'AES-256-CBC')) - ->encrypt('APP_NAME=Laravel') + ->encrypt('APP_NAME=Hypervel') ); $this->artisan('env:decrypt', ['--force' => true, '--key' => 'base64:' . base64_encode($key)]) @@ -115,7 +115,7 @@ public function testItGeneratesTheEnvironmentFileWithGeneratedKey(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME=Laravel', 0640); + ->with(base_path('.env'), 'APP_NAME=Hypervel', 0640); } public function testItGeneratesTheEnvironmentFileWithUserProvidedKey(): void @@ -130,7 +130,7 @@ public function testItGeneratesTheEnvironmentFileWithUserProvidedKey(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--cipher' => 'aes-128-gcm', '--key' => 'abcdefghijklmnop']) @@ -138,7 +138,7 @@ public function testItGeneratesTheEnvironmentFileWithUserProvidedKey(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); } public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void @@ -158,7 +158,7 @@ public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void ->once() ->andReturn( (new Encrypter('ponmlkjihgfedcbaponmlkjihgfedcba', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Three"') + ->encrypt('APP_NAME="Hypervel Three"') ); $this->artisan('env:decrypt') @@ -166,7 +166,7 @@ public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Three"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Three"', 0600); } finally { if ($hadEncryptionKey) { $_SERVER['HYPERVEL_ENV_ENCRYPTION_KEY'] = $previousEncryptionKey; @@ -188,7 +188,7 @@ public function testItGeneratesTheEnvironmentFileWhenForcing(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--force' => true, '--key' => 'abcdefghijklmnop', '--cipher' => 'aes-128-gcm']) @@ -196,13 +196,13 @@ public function testItGeneratesTheEnvironmentFileWhenForcing(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0640); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0640); } public function testItDecryptsMultiLineEnvironmentCorrectly(): void { $contents = <<<'Text' - APP_NAME=Laravel + APP_NAME=Hypervel APP_ENV=local APP_DEBUG=true APP_URL=http://localhost @@ -214,7 +214,7 @@ public function testItDecryptsMultiLineEnvironmentCorrectly(): void DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 - DB_DATABASE=laravel + DB_DATABASE=hypervel DB_USERNAME=root DB_PASSWORD= Text; @@ -252,7 +252,7 @@ public function testItWritesTheEnvironmentFileCustomFilename(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--filename' => '.env']) @@ -260,7 +260,7 @@ public function testItWritesTheEnvironmentFileCustomFilename(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); } public function testItWritesTheEnvironmentFileCustomPath(): void @@ -275,7 +275,7 @@ public function testItWritesTheEnvironmentFileCustomPath(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--path' => '/tmp']) @@ -283,7 +283,7 @@ public function testItWritesTheEnvironmentFileCustomPath(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with('/tmp' . DIRECTORY_SEPARATOR . '.env.production', 'APP_NAME="Laravel Two"', 0600); + ->with('/tmp' . DIRECTORY_SEPARATOR . '.env.production', 'APP_NAME="Hypervel Two"', 0600); } public function testItWritesTheEnvironmentFileCustomPathAndFilename(): void @@ -298,7 +298,7 @@ public function testItWritesTheEnvironmentFileCustomPathAndFilename(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--filename' => '.env', '--path' => '/tmp']) @@ -306,7 +306,7 @@ public function testItWritesTheEnvironmentFileCustomPathAndFilename(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with('/tmp' . DIRECTORY_SEPARATOR . '.env', 'APP_NAME="Laravel Two"', 0600); + ->with('/tmp' . DIRECTORY_SEPARATOR . '.env', 'APP_NAME="Hypervel Two"', 0600); } public function testItCannotOverwriteEncryptedFiles(): void @@ -332,7 +332,7 @@ public function testItGeneratesTheEnvironmentFileWithInteractivelyUserProvidedKe ->once() ->andReturn( (new Encrypter($key = 'abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--cipher' => 'aes-128-gcm']) @@ -341,7 +341,7 @@ public function testItGeneratesTheEnvironmentFileWithInteractivelyUserProvidedKe ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); } public function testItAutoDetectsAndDecryptsReadableFormat(): void @@ -350,7 +350,7 @@ public function testItAutoDetectsAndDecryptsReadableFormat(): void $encrypter = new Encrypter($key, 'AES-256-CBC'); // Create readable format encrypted content - $encryptedContent = 'APP_NAME=' . $encrypter->encryptString('Laravel') . "\n" + $encryptedContent = 'APP_NAME=' . $encrypter->encryptString('Hypervel') . "\n" . 'APP_ENV=' . $encrypter->encryptString('local'); $this->filesystem->shouldReceive('exists') @@ -368,7 +368,7 @@ public function testItAutoDetectsAndDecryptsReadableFormat(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), "APP_NAME=Laravel\nAPP_ENV=local\n", 0600); + ->with(base_path('.env'), "APP_NAME=Hypervel\nAPP_ENV=local\n", 0600); } public function testItStillDecryptsBlobFormat(): void @@ -377,7 +377,7 @@ public function testItStillDecryptsBlobFormat(): void $encrypter = new Encrypter($key, 'AES-256-CBC'); // Create blob format (entire file encrypted as one) - $originalContent = "APP_NAME=Laravel\nAPP_ENV=local"; + $originalContent = "APP_NAME=Hypervel\nAPP_ENV=local"; $encryptedContent = $encrypter->encrypt($originalContent); $this->filesystem->shouldReceive('exists') @@ -404,7 +404,7 @@ public function testItDecryptsBlobFormatWithNewlineInContent(): void $encrypter = new Encrypter($key, 'AES-256-CBC'); // Create blob format and inject a newline (simulating wrapped base64) - $originalContent = "APP_NAME=Laravel\nAPP_ENV=local"; + $originalContent = "APP_NAME=Hypervel\nAPP_ENV=local"; $encryptedContent = $encrypter->encrypt($originalContent); // Insert a newline in the middle of the base64 string diff --git a/tests/Integration/Console/EnvironmentEncryptCommandTest.php b/tests/Integration/Console/EnvironmentEncryptCommandTest.php index 995d90be1c..eda3c53152 100644 --- a/tests/Integration/Console/EnvironmentEncryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentEncryptCommandTest.php @@ -23,7 +23,7 @@ protected function setUp(): void $this->filesystem = m::spy(Filesystem::class); $this->filesystem->shouldReceive('get') - ->andReturn('APP_NAME=Laravel'); + ->andReturn('APP_NAME=Hypervel'); $this->filesystem->shouldReceive('replace'); $this->filesystem->shouldReceive('chmod')->andReturn('0640'); File::swap($this->filesystem); @@ -208,7 +208,7 @@ public function testItEncryptsInReadableFormat(): void $filesystem->shouldReceive('get') ->with(base_path('.env')) ->once() - ->andReturn("APP_NAME=Laravel\nAPP_ENV=local"); + ->andReturn("APP_NAME=Hypervel\nAPP_ENV=local"); $filesystem->shouldReceive('replace') ->once() ->with(base_path('.env.encrypted'), m::on(function ($content) { @@ -239,7 +239,7 @@ public function testItSkipsCommentsAndBlankLinesInReadableFormat(): void $filesystem->shouldReceive('get') ->with(base_path('.env')) ->once() - ->andReturn("# Comment\nAPP_NAME=Laravel\n\nAPP_ENV=local"); + ->andReturn("# Comment\nAPP_NAME=Hypervel\n\nAPP_ENV=local"); $filesystem->shouldReceive('replace') ->once() ->with(base_path('.env.encrypted'), m::on(function ($content) { diff --git a/tests/Integration/Console/GeneratorCommandTest.php b/tests/Integration/Console/GeneratorCommandTest.php new file mode 100644 index 0000000000..9cca6d7942 --- /dev/null +++ b/tests/Integration/Console/GeneratorCommandTest.php @@ -0,0 +1,79 @@ +artisan('make:command', ['name' => 'FooCommand.php']) + ->assertExitCode(0); + + $this->assertFilenameExists('app/Console/Commands/FooCommand.php'); + + $this->assertFileContains([ + 'class FooCommand extends Command', + ], 'app/Console/Commands/FooCommand.php'); + } + + public function testItChopsPhpExtensionFromMakeViewCommands(): void + { + $this->artisan('make:view', ['name' => 'foo.php']) + ->assertExitCode(0); + + $this->assertFilenameExists('resources/views/foo/php.blade.php'); + } + + public function testItOnlyChopsPhpExtensionFromFilename(): void + { + $this->artisan('make:test', ['name' => 'fixtures.php/SomeTest']) + ->assertExitCode(0); + + $this->assertFilenameExists('tests/Feature/fixtures.php/SomeTest.php'); + + $this->assertFileContains([ + 'class SomeTest extends TestCase', + ], 'tests/Feature/fixtures.php/SomeTest.php'); + } + + #[DataProvider('reservedNamesDataProvider')] + public function testItCannotGenerateClassUsingReservedName(string $given): void + { + $path = 'app/Console/Commands/' . $given . '.php'; + $this->files[] = $path; + + $this->artisan('make:command', ['name' => $given]) + ->expectsOutputToContain('The name "' . $given . '" is reserved by PHP.') + ->assertExitCode(0); + + $this->assertFilenameDoesNotExists($path); + } + + /** + * Provide reserved class names. + */ + public static function reservedNamesDataProvider(): Generator + { + yield ['__halt_compiler']; + yield ['__HALT_COMPILER']; + yield ['array']; + yield ['ARRAY']; + yield ['__class__']; + yield ['__CLASS__']; + } +} diff --git a/tests/Integration/Console/JobSchedulingTest.php b/tests/Integration/Console/JobSchedulingTest.php index c3fdcc9c8f..d5680a7e87 100644 --- a/tests/Integration/Console/JobSchedulingTest.php +++ b/tests/Integration/Console/JobSchedulingTest.php @@ -9,7 +9,9 @@ use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; +use Mockery as m; class JobSchedulingTest extends TestCase { @@ -105,7 +107,11 @@ public function testJobQueuingNormalizesIntegerBackedEnumQueueAndConnection(): v public function testJobQueuingRespectsQueueRoutes(): void { - Queue::fake(); + // QueueFake ignores connection names, so verify selection separately from its push assertions. + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->twice()->with(null)->andReturnSelf(); + $queue->shouldReceive('connection')->once()->with('some-connection')->andReturnSelf(); + Queue::swap($queue); Queue::route(JobWithDefaultQueue::class, 'default-queue'); Queue::route(JobWithoutDefaultQueue::class, 'fallback-queue'); @@ -126,7 +132,7 @@ public function testJobQueuingRespectsQueueRoutes(): void // Own queue takes precedence over default Queue::assertPushedOn('test-queue', JobWithDefaultQueue::class); Queue::assertPushedOn('fallback-queue', JobWithoutDefaultQueue::class); - Queue::connection('some-queue')->assertPushedOn('some-queue', JobWithoutDefaultConnection::class); + Queue::assertPushedOn('some-queue', JobWithoutDefaultConnection::class); } } diff --git a/tests/Integration/Filesystem/StorageFakeTest.php b/tests/Integration/Filesystem/StorageFakeTest.php index c953445c3a..e54a4a2d44 100644 --- a/tests/Integration/Filesystem/StorageFakeTest.php +++ b/tests/Integration/Filesystem/StorageFakeTest.php @@ -8,10 +8,63 @@ use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Support\Facades\Storage; use Hypervel\Testbench\TestCase; +use League\Flysystem\UnableToReadFile; class StorageFakeTest extends TestCase { - public function testFakePreservesOriginalDiskThrowConfig() + public function testFakeWhenDiskNotConfiguredDoesNotThrowExceptionOnError(): void + { + $result = Storage::fake('test')->get('nonExistentFile'); + + $this->assertNull($result); + } + + public function testFakeWhenThrowSetToDiskThrowsExceptionOnError(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $this->expectException(UnableToReadFile::class); + Storage::fake('test')->get('nonExistentFile'); + } + + public function testFakeWhenThrowOverwrittenUsesOverwrite(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $result = Storage::fake('test', ['throw' => false])->get('nonExistentFile'); + $this->assertNull($result); + } + + public function testPersistentFakeWhenDiskNotConfiguredDoesNotThrowExceptionOnError(): void + { + $result = Storage::persistentFake('test')->get('nonExistentFile'); + + $this->assertNull($result); + } + + public function testPersistentFakeWhenThrowSetToDiskThrowsExceptionOnError(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $this->expectException(UnableToReadFile::class); + Storage::persistentFake('test')->get('nonExistentFile'); + } + + public function testPersistentFakeWhenThrowOverwrittenUsesOverwrite(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $result = Storage::persistentFake('test', ['throw' => false])->get('nonExistentFile'); + $this->assertNull($result); + } + + public function testStorageFakeMethodsWithEnums(): void + { + $this->assertNull(Storage::persistentFake(StorageFakeStringDisk::Test)->get('nonExistentFile')); + $this->assertNull(Storage::fake(StorageFakeStringDisk::Public)->get('nonExistentFile')); + } + + public function testFakePreservesOriginalDiskThrowConfig(): void { config(['filesystems.disks.local.throw' => true]); @@ -21,7 +74,7 @@ public function testFakePreservesOriginalDiskThrowConfig() $this->assertTrue($fake->getConfig()['throw']); } - public function testFakeDefaultsThrowToFalseWhenNotConfigured() + public function testFakeDefaultsThrowToFalseWhenNotConfigured(): void { config(['filesystems.disks.local' => ['driver' => 'local', 'root' => storage_path('app')]]); @@ -31,7 +84,7 @@ public function testFakeDefaultsThrowToFalseWhenNotConfigured() $this->assertFalse($fake->getConfig()['throw']); } - public function testFakeRegistersTemporaryUploadUrlBuilder() + public function testFakeRegistersTemporaryUploadUrlBuilder(): void { $fake = Storage::fake('local'); @@ -40,7 +93,7 @@ public function testFakeRegistersTemporaryUploadUrlBuilder() $this->assertTrue($fake->providesTemporaryUploadUrls()); } - public function testFakeTemporaryUploadUrlReturnsArrayWithUrlAndHeaders() + public function testFakeTemporaryUploadUrlReturnsArrayWithUrlAndHeaders(): void { $fake = Storage::fake('local'); @@ -52,7 +105,7 @@ public function testFakeTemporaryUploadUrlReturnsArrayWithUrlAndHeaders() $this->assertArrayHasKey('headers', $result); } - public function testFakeUsesParallelTestingTokenSuffix() + public function testFakeUsesParallelTestingTokenSuffix(): void { ParallelTesting::resolveTokenUsing(fn () => '42'); @@ -68,7 +121,7 @@ public function testFakeUsesParallelTestingTokenSuffix() } } - public function testPersistentFakePreservesOriginalDiskThrowConfig() + public function testPersistentFakePreservesOriginalDiskThrowConfig(): void { config(['filesystems.disks.local.throw' => true]); @@ -111,3 +164,9 @@ enum StorageFakeDisk: int { case Zero = 0; } + +enum StorageFakeStringDisk: string +{ + case Test = 'test'; + case Public = 'public'; +} diff --git a/tests/Integration/Horizon/Feature/ClearCommandTest.php b/tests/Integration/Horizon/Feature/ClearCommandTest.php index 52742cfac8..6008332b13 100644 --- a/tests/Integration/Horizon/Feature/ClearCommandTest.php +++ b/tests/Integration/Horizon/Feature/ClearCommandTest.php @@ -6,11 +6,15 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Queue\ClearableQueue; -use Hypervel\Contracts\Queue\Queue; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Horizon\Console\ClearCommand; use Hypervel\Horizon\Contracts\JobRepository; +use Hypervel\Horizon\RedisQueue; use Hypervel\Horizon\Repositories\RedisJobRepository; use Hypervel\Queue\QueueManager; +use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Facades\Redis; +use Hypervel\Tests\Integration\Horizon\Feature\Jobs\BasicJob; use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -26,7 +30,16 @@ protected function defineEnvironment(ApplicationContract $app): void $config = $app->make('config'); $config->set('queue.connections.redis.queue', 'default'); - $config->set('queue.connections.secondary.queue', 'secondary-default'); + $config->set('database.redis.secondary', array_replace($config->array('database.redis.default'), [ + 'prefix' => 'horizon_clear_secondary:', + ])); + $config->set('queue.connections.secondary', array_replace($config->array('queue.connections.redis'), [ + 'connection' => 'secondary', + 'queue' => 'secondary-default', + ])); + $config->set('queue.connections.redis-long', array_replace($config->array('queue.connections.redis'), [ + 'retry_after' => 3600, + ])); $config->set('queue.connections.0.queue', 'zero-default'); } @@ -41,10 +54,12 @@ public function testCommandPreservesZeroAndDefaultsEmptyIdentifiers( config()->set('horizon.defaults', $defaults); $jobRepository = m::mock(RedisJobRepository::class); - $jobRepository->shouldReceive('purge')->once()->with($expectedQueue); + $jobRepository->shouldReceive('purge')->once()->with($expectedQueue, $expectedConnection); $this->app->instance(JobRepository::class, $jobRepository); - $resolvedQueue = m::mock(Queue::class, ClearableQueue::class); + $resolvedQueue = m::mock(RedisQueue::class); + $resolvedQueue->shouldReceive('getQueue')->once()->with($expectedQueue)->andReturn('queues:' . $expectedQueue); + $resolvedQueue->shouldReceive('getConnectionName')->once()->andReturn($expectedConnection); $resolvedQueue->shouldReceive('clear')->once()->with($expectedQueue)->andReturn(1); $manager = m::mock(QueueManager::class); @@ -78,4 +93,75 @@ public static function queueIdentifierProvider(): array 'omitted defaults' => ['', '', [], 'redis', 'default'], ]; } + + public function testClearingAForwardedQueueRemovesItsDestinationMetadata(): void + { + // The second forward exposes accidentally resolving the destination twice. + Queue::forward(['reports' => 'processing', 'processing' => 'archive']); + $id = Queue::push(new BasicJob, queue: 'reports'); + $this->assertSame('processing', Redis::connection('horizon')->hget($id, 'queue')); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--queue' => 'reports', '--force' => true]) + ->assertExitCode(0); + + $this->assertSame(0, Queue::size('reports')); + $this->assertSame(0, $this->recentJobs()); + $this->assertSame(0, Redis::connection('horizon')->exists($id)); + } + + public function testClearingOneConnectionPreservesAnotherConnectionsJobs(): void + { + $id = Queue::push(new BasicJob, queue: 'reports'); + $otherId = Queue::connection('secondary')->push(new BasicJob, queue: 'reports'); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--queue' => 'reports', '--force' => true]) + ->assertExitCode(0); + + $this->assertSame(0, Queue::size('reports')); + $this->assertSame(1, Queue::connection('secondary')->size('reports')); + $this->assertSame(0, Redis::connection('horizon')->exists($id)); + $this->assertSame('pending', Redis::connection('horizon')->hget($otherId, 'status')); + } + + public function testClearingSharedStoragePreservesOtherConnectionRecordsUntilTheyExpire(): void + { + $id = Queue::push(new BasicJob, queue: 'reports'); + $otherId = Queue::connection('redis-long')->push(new BasicJob, queue: 'reports'); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--queue' => 'reports', '--force' => true]) + ->assertExitCode(0); + + $this->assertSame(0, Queue::connection('redis-long')->size('reports')); + $this->assertSame(0, Redis::connection('horizon')->exists($id)); + $this->assertSame('pending', Redis::connection('horizon')->hget($otherId, 'status')); + $this->assertGreaterThan(0, Redis::connection('horizon')->ttl($otherId)); + } + + public function testClearingANonHorizonConnectionDoesNotPurgeHorizonJobs(): void + { + $jobRepository = m::mock(JobRepository::class); + $jobRepository->shouldNotReceive('purge'); + $this->app->instance(JobRepository::class, $jobRepository); + + $resolvedQueue = m::mock(QueueContract::class, ClearableQueue::class); + $resolvedQueue->shouldReceive('clear')->once()->with('default')->andReturn(1); + + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->once()->with('redis')->andReturn($resolvedQueue); + $this->app->instance('queue', $manager); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--force' => true]) + ->assertExitCode(0); + } + + public function testUnsupportedConnectionsFailBeforePurgingHorizonJobs(): void + { + $jobRepository = m::mock(JobRepository::class); + $jobRepository->shouldNotReceive('purge'); + $this->app->instance(JobRepository::class, $jobRepository); + + $this->artisan('horizon:clear', ['connection' => 'sync', '--force' => true]) + ->expectsOutputToContain('Clearing queues is not supported on [SyncQueue]') + ->assertExitCode(1); + } } diff --git a/tests/Integration/Horizon/Feature/QueueProcessingTest.php b/tests/Integration/Horizon/Feature/QueueProcessingTest.php index 60df7ec315..4152bdfbca 100644 --- a/tests/Integration/Horizon/Feature/QueueProcessingTest.php +++ b/tests/Integration/Horizon/Feature/QueueProcessingTest.php @@ -7,12 +7,16 @@ use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Horizon\Contracts\JobRepository; +use Hypervel\Horizon\Events\JobDeleted; use Hypervel\Horizon\Events\JobPending; use Hypervel\Horizon\Events\JobPushed; +use Hypervel\Horizon\Events\JobReleased; use Hypervel\Horizon\Events\JobReserved; use Hypervel\Horizon\Events\JobsMigrated; +use Hypervel\Horizon\Events\RedisEvent; use Hypervel\Horizon\RedisQueue; use Hypervel\Queue\InvalidPayloadException; +use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Queue\Queue as BaseQueue; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Support\CarbonImmutable; @@ -88,6 +92,36 @@ public function testDirectRawPushDoesNotInheritThePreviousJob(): void $this->assertSame([], $payload['tags']); } + public function testForwardedJobsKeepTheirWorkerQueueAndReportTheirDestination(): void + { + Queue::forward(['default' => 'processing', 'processing' => 'archive']); + $events = []; + + Event::listen([JobPushed::class, JobReserved::class, JobReleased::class, JobDeleted::class], function (RedisEvent $event) use (&$events): void { + $events[] = [$event::class, $event->queue]; + }); + + $id = Queue::push(new Jobs\BasicJob); + $job = Queue::pop(); + $this->assertInstanceOf(RedisJob::class, $job); + $this->assertSame('default', $job->getQueue()); + $this->assertSame('processing', Redis::connection('horizon')->hget($id, 'queue')); + + $job->release(0); + $options = $this->workerOptions(); + $options->maxTries = 2; + $this->worker()->runNextJob('redis', 'default', $options); + + $this->assertSame('completed', Redis::connection('horizon')->hget($id, 'status')); + $this->assertSame([ + [JobPushed::class, 'processing'], + [JobReserved::class, 'processing'], + [JobReleased::class, 'processing'], + [JobReserved::class, 'processing'], + [JobDeleted::class, 'processing'], + ], $events); + } + public function testDirectRawPushPreservesExistingHorizonClassification(): void { /** @var RedisQueue $queue */ diff --git a/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php b/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php index 1763e7ff87..60c43e8cf5 100644 --- a/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php +++ b/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php @@ -69,7 +69,7 @@ public function testItSavesMicrosecondsAsAFloatAndDisregardsTheLocale() } } - public function testItRemovesRecentJobsWhenQueueIsPurged() + public function testItRemovesRecentJobsWhenQueueIsPurged(): void { $repository = $this->app->make(JobRepository::class); @@ -77,7 +77,7 @@ public function testItRemovesRecentJobsWhenQueueIsPurged() $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '2', 'displayName' => 'second']))); $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '3', 'displayName' => 'third']))); $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '4', 'displayName' => 'fourth']))); - $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '5', 'displayName' => 'fifth']))); + $repository->pushed('other', 'email-processing', new JobPayload(json_encode(['id' => '5', 'displayName' => 'fifth']))); $repository->completed(new JobPayload(json_encode(['id' => '1', 'displayName' => 'first']))); $repository->completed(new JobPayload(json_encode(['id' => '2', 'displayName' => 'second']))); @@ -93,6 +93,24 @@ public function testItRemovesRecentJobsWhenQueueIsPurged() $this->assertCount(2, $repository->getJobs(['1', '2', '3', '4', '5'])); } + public function testPurgingOneConnectionPreservesOtherConnectionsAndCompletedJobs(): void + { + $repository = $this->app->make(JobRepository::class); + $payloads = []; + + foreach (['pending' => '0', 'reserved' => '0', 'completed' => '0', 'other' => '1'] as $id => $connection) { + $payloads[$id] = new JobPayload(json_encode(['id' => $id, 'displayName' => $id])); + $repository->pushed($connection, 'email-processing', $payloads[$id]); + } + + $repository->reserved('0', 'email-processing', $payloads['reserved']); + $repository->completed($payloads['completed']); + + $this->assertSame(2, $repository->purge('email-processing', '0')); + $this->assertSame(['completed', 'other'], $repository->getRecent()->pluck('id')->sort()->values()->all()); + $this->assertSame(['other'], $repository->getPending()->pluck('id')->all()); + } + public function testItWillDeleteAFailedJob() { $repository = $this->app->make(JobRepository::class); diff --git a/tests/Integration/Http/ResourceTest.php b/tests/Integration/Http/ResourceTest.php index c67f949342..0ac0d5bc19 100644 --- a/tests/Integration/Http/ResourceTest.php +++ b/tests/Integration/Http/ResourceTest.php @@ -59,6 +59,9 @@ class ResourceTest extends TestCase public function testResourceMayBeConvertedToArray(): void { $resource = new class((new User)->forceFill(['id' => 1, 'name' => 'Taylor Otwell'])) extends JsonResource { + /** + * Transform the resource into an array. + */ public function toArray(Request $request): array { return [ @@ -80,7 +83,7 @@ public function toArray(Request $request): array } }; - $request = Request::create('GET', '/users'); + $request = Request::create('/users', 'GET'); tap($resource->toArray($request), function ($userAsArray) use ($request) { $this->assertSame(1, $userAsArray['id']); diff --git a/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php b/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php index c8355885b5..66b2c44fd4 100644 --- a/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php +++ b/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php @@ -16,11 +16,14 @@ class ResourceCollectionTest extends TestCase #[DataProvider('toArrayDataProvider')] public function testItCanReturnToArray(ResourceCollection $collection, mixed $expected): void { - $request = Request::create('GET', '/'); + $request = Request::create('/', 'GET'); $this->assertSame($expected, $collection->toArray($request)); } + /** + * Provide resource collections and their expected arrays. + */ public static function toArrayDataProvider(): iterable { yield [ @@ -49,6 +52,9 @@ public static function toArrayDataProvider(): iterable yield [ new class(['list' => new Fluent(['id' => 1]), 'total' => 1]) extends ResourceCollection { + /** + * Transform the resource into a JSON array. + */ public function toArray(Request $request): array { return $this->resource->toArray(); diff --git a/tests/Integration/Mail/SendingQueuedMailTest.php b/tests/Integration/Mail/SendingQueuedMailTest.php index 01f4ce8e70..986598d4fc 100644 --- a/tests/Integration/Mail/SendingQueuedMailTest.php +++ b/tests/Integration/Mail/SendingQueuedMailTest.php @@ -10,7 +10,9 @@ use Hypervel\Queue\Middleware\RateLimited; use Hypervel\Support\Facades\Mail; use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; +use Mockery as m; class SendingQueuedMailTest extends TestCase { @@ -39,13 +41,30 @@ public function testMailIsSentWithDefaultLocale(): void public function testMailIsSentWhenRoutingQueue(): void { - Queue::fake(); + // QueueFake ignores connection names, so verify selection separately from its push assertions. + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('mail-connection')->andReturnSelf(); + Queue::swap($queue); Queue::route(Mailable::class, 'mail-queue', 'mail-connection'); Mail::to('test@mail.com')->queue(new SendingQueuedMailTestMail); - Queue::connection('mail-connection')->assertPushedOn('mail-queue', SendQueuedMailable::class); + Queue::assertPushedOn('mail-queue', SendQueuedMailable::class); + } + + public function testMailIsSentWhenForwardingQueue(): void + { + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('mail-connection')->andReturnSelf(); + Queue::swap($queue); + + Queue::forward('mail-queue', 'main', 'mail-connection'); + + Mail::to('test@mail.com')->queue(new SendingQueuedForwardedMailTestMail); + + // The fake records the logical queue; storage drivers apply the destination forward. + Queue::assertPushedOn('mail-queue', SendQueuedMailable::class); } public function testMailIsSentWithDelay(): void @@ -77,3 +96,16 @@ public function middleware(): array return [new RateLimited('limiter')]; } } + +class SendingQueuedForwardedMailTestMail extends Mailable +{ + public string $queue = 'mail-queue'; + + /** + * Build the message. + */ + public function build(): static + { + return $this->view('view'); + } +} diff --git a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php new file mode 100644 index 0000000000..bf829eb1df --- /dev/null +++ b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php @@ -0,0 +1,390 @@ +createQueue(); + $routes = new QueueRoutes; + $routes->forward(['reports' => 'processing', 'processing' => 'archive'], connection: $connection); + $database->getContainer()->instance('queue.routes', $routes); + $payload = json_encode(['job' => stdClass::class, 'data' => []]); + $primary = m::mock(QueueContract::class); + $primary->shouldReceive('pushRaw')->once()->with($payload, $delegatedQueue)->andThrow(new RuntimeException('Primary unavailable.')); + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->once()->with('primary')->andReturn($primary); + $manager->shouldReceive('connection')->once()->with('database')->andReturn($database); + $queue = new FailoverQueue($manager, $events, ['primary', 'database']); + $queue->setContainer($database->getContainer()); + $queue->setConnectionName('failover'); + + $id = $queue->pushRaw($payload, 'reports'); + + $this->assertSame('processing', $database->getDatabase()->table('jobs')->find($id)->queue); + $this->assertSame(1, $database->getDatabase()->table('jobs')->count()); + } + + public function testForwardedQueueReservesAndReleasesUsingTheLogicalName(): void + { + [$queue] = $this->createQueue(); + $routes = new QueueRoutes; + $routes->forward(['reports' => 'processing', 'processing' => 'archive']); + $queue->getContainer()->instance('queue.routes', $routes); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []]), 'reports'); + + $this->assertSame('processing', $queue->getDatabase()->table('jobs')->find($id)->queue); + + $job = $queue->pop('reports'); + + $this->assertSame((string) $id, $job?->getJobId()); + $this->assertSame('reports', $job->getQueue()); + $this->assertSame(1, $job->attempts()); + + $job->release(); + + $record = $queue->getDatabase()->table('jobs')->sole(); + $this->assertSame('processing', $record->queue); + $this->assertSame(1, $record->attempts); + $this->assertNull($record->reserved_at); + $this->assertSame(2, $queue->pop('reports')?->attempts()); + } + + #[TestWith([0])] + #[TestWith([1])] + public function testFailedReservationDoesNotBlockTheNextJob(int $transactionLevel): void + { + [$queue, $events] = $this->createQueue(); + $database = $queue->getDatabase(); + $payload = json_encode(['job' => stdClass::class, 'data' => []]); + $failedId = $queue->pushRaw($payload); + $nextId = $queue->pushRaw($payload); + $database->table('jobs')->where('id', $failedId)->update(['attempts' => 65535]); + $failed = null; + $events->listen(JobFailed::class, static function (JobFailed $event) use (&$failed): void { + $failed = $event; + }); + + if ($transactionLevel === 1) { + $database->beginTransaction(); + } + + try { + try { + $queue->pop(); + $this->fail('Expected the attempts constraint to reject the reservation.'); + } catch (QueryException $exception) { + $this->assertSame($exception, $failed?->exception); + } + + $this->assertSame((string) $failedId, $failed->job->getJobId()); + $this->assertSame('database', $failed->connectionName); + $this->assertFalse($database->table('jobs')->where('id', $failedId)->exists()); + $this->assertSame((string) $nextId, $queue->pop()?->getJobId()); + $this->assertSame($transactionLevel, $database->transactionLevel()); + } finally { + if ($transactionLevel === 1) { + $database->rollBack(); + } + } + } + + public function testNestedConcurrencyFailureDoesNotFailTheJob(): void + { + $pdo = new PDO('sqlite::memory:'); + [$queue, $events] = $this->createQueue($pdo); + $database = $queue->getDatabase(); + $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $previous = new class extends PDOException { + /** + * Create a driver exception identified only by its SQLSTATE. + */ + public function __construct() + { + parent::__construct('Could not serialize access due to concurrent update.'); + + $this->code = '40001'; + } + }; + $failure = new QueryException('database', 'update jobs', [], $previous); + $database->beforeExecuting(static function (string $query) use ($failure): void { + if (str_starts_with($query, 'update ')) { + throw $failure; + } + }); + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $database->beginTransaction(); + + try { + try { + $queue->pop(); + $this->fail('Expected the nested concurrency failure.'); + } catch (DeadlockException $exception) { + $this->assertSame($failure, $exception->getPrevious()); + } + + $this->assertFalse($failed); + $this->assertSame(1, $database->transactionLevel()); + $this->assertSame(1, (int) $pdo->query('select count(*) from jobs')->fetchColumn()); + } finally { + $database->rollBack(); + } + } + + #[TestWith(['before', false])] + #[TestWith(['executed', false])] + #[TestWith(['duration', false])] + #[TestWith(['before', true])] + #[TestWith(['executed', true])] + #[TestWith(['duration', true])] + public function testQueryObserverFailureKeepsTheJobAvailable(string $observer, bool $queryFailure): void + { + [$queue, $events] = $this->createQueue(); + $database = $queue->getDatabase(); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $failure = $queryFailure ? null : new RuntimeException('Query observer failed.'); + $callback = static function (string $query) use ($database, $queryFailure, &$failure): void { + if (! str_starts_with($query, 'update ')) { + return; + } + + if (! $queryFailure) { + throw $failure; + } + + try { + $database->statement('insert into missing_query_audit (message) values (?)', ['query observed']); + } catch (QueryException $exception) { + $failure = $exception; + + throw $exception; + } + }; + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + + if ($observer === 'before') { + $database->beforeExecuting($callback); + } elseif ($observer === 'executed') { + $events->listen(QueryExecuted::class, static function (QueryExecuted $event) use ($callback): void { + $callback($event->sql); + }); + } else { + // A negative threshold fires even at zero measured duration. Selection runs + // first, so re-arm the one-shot handler for the reservation update. + $database->whenQueryingForLongerThan(-1, static function (PdoConnection $connection, QueryExecuted $event) use ($callback): void { + $callback($event->sql); + }); + + $database->beforeExecuting(static function (string $query) use ($database): void { + if (str_starts_with($query, 'update ')) { + $database->allowQueryDurationHandlersToRunAgain(); + } + }); + } + + try { + $queue->pop(); + $this->fail('Expected the query observer failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $record = $database->table('jobs')->find($id); + $this->assertNotNull($record); + $this->assertSame(0, $record->attempts); + $this->assertNull($record->reserved_at); + $this->assertFalse($failed); + } + + public function testObserverFailureUpdatingAnotherJobKeepsTheJobAvailable(): void + { + [$queue, $events] = $this->createQueue(); + $database = $queue->getDatabase(); + $payload = json_encode(['job' => stdClass::class, 'data' => []]); + $id = $queue->pushRaw($payload); + $otherId = $queue->pushRaw($payload); + $failure = null; + $reservationSql = null; + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $events->listen(QueryExecuted::class, static function (QueryExecuted $event) use ($database, $otherId, &$failure, &$reservationSql): void { + if ($reservationSql !== null || ! str_starts_with($event->sql, 'update ')) { + return; + } + + $reservationSql = $event->sql; + + try { + $database->table('jobs')->where('id', $otherId)->update([ + 'reserved_at' => 1, + 'attempts' => 65536, + ]); + } catch (QueryException $exception) { + $failure = $exception; + + throw $exception; + } + }); + + try { + $queue->pop(); + $this->fail('Expected the observer update to fail.'); + } catch (QueryException $exception) { + $this->assertSame($failure, $exception); + $this->assertSame($reservationSql, $exception->getSql()); + } + + $record = $database->table('jobs')->find($id); + $this->assertNotNull($record); + $this->assertSame(0, $record->attempts); + $this->assertNull($record->reserved_at); + $this->assertFalse($failed); + } + + public function testCommittedListenerFailureKeepsTheReservedJob(): void + { + [$queue, $events] = $this->createQueue(); + $database = $queue->getDatabase(); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $failure = new RuntimeException('Committed listener failed.'); + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $events->listen(TransactionCommitted::class, static function () use ($failure): never { + throw $failure; + }); + + try { + $queue->pop(); + $this->fail('Expected the committed listener failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $record = $database->table('jobs')->find($id); + $this->assertNotNull($record); + $this->assertNotNull($record->reserved_at); + $this->assertSame(1, $record->attempts); + $this->assertFalse($failed); + } + + public function testFailedRollbackDoesNotFailTheJobInTheOpenTransaction(): void + { + $pdo = new ReservationRollbackFailingPdo('sqlite::memory:'); + [$queue, $events] = $this->createQueue($pdo); + $database = $queue->getDatabase(); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $database->table('jobs')->where('id', $id)->update(['attempts' => 65535]); + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $pdo->failRollback = true; + + try { + try { + $queue->pop(); + $this->fail('Expected the original reservation failure.'); + } catch (QueryException $exception) { + $this->assertStringContainsString('CHECK constraint failed', $exception->getMessage()); + } + + $this->assertSame(1, $database->transactionLevel()); + $this->assertFalse($failed); + $this->assertSame(1, (int) $pdo->query('select count(*) from jobs')->fetchColumn()); + } finally { + $pdo->failRollback = false; + $database->rollBack(); + } + } + + /** + * Create a queue backed by an isolated SQLite database with an enforced attempts limit. + * + * @return array{DatabaseQueue, Dispatcher} + */ + private function createQueue(?PDO $pdo = null): array + { + // Distinguish the storage connection from the queue name and default resolver key. + $database = new PdoConnection($pdo ?? new PDO('sqlite::memory:'), config: ['name' => 'queue-storage']); + $database->setQueryGrammar(new SQLiteGrammar($database)); + + // SQLite ignores integer widths, so enforce the migration's unsignedSmallInteger ceiling explicitly. + $database->statement('create table jobs ( + id integer primary key autoincrement, + queue text not null, + payload text not null, + attempts integer not null check (attempts <= 65535), + reserved_at integer, + available_at integer not null, + created_at integer not null + )'); + + $container = new Container; + $events = new Dispatcher($container); + $container->instance(DispatcherContract::class, $events); + $database->setEventDispatcher($events); + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($database); + $queue = new DatabaseQueue($resolver, null, 'jobs'); + $queue->setContainer($container); + $queue->setConnectionName('database'); + + return [$queue, $events]; + } +} + +class ReservationRollbackFailingPdo extends PDO +{ + public bool $failRollback = false; + + /** + * Fail the physical rollback when testing reservation cleanup. + */ + public function rollBack(): bool + { + if ($this->failRollback) { + throw new RuntimeException('Physical rollback failed.'); + } + + return parent::rollBack(); + } +} diff --git a/tests/Integration/Queue/Database/Sqlite/QueueCapsuleTest.php b/tests/Integration/Queue/Database/Sqlite/QueueCapsuleTest.php new file mode 100644 index 0000000000..89f5dc6489 --- /dev/null +++ b/tests/Integration/Queue/Database/Sqlite/QueueCapsuleTest.php @@ -0,0 +1,44 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:']); + $connection = $database->getConnection(); + $connection->getSchemaBuilder()->create('jobs', static function (Blueprint $table): void { + $table->id(); + $table->string('queue'); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + $container->instance('db', $database->getDatabaseManager()); + $queue = new QueueCapsule($container); + $queue->addConnection([ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'reports', + ]); + $queue->forward('reports', 'processing'); + + $id = $queue->getConnection()->pushRaw('{"job":"example","data":[]}'); + + $this->assertSame('processing', $connection->table('jobs')->find($id)->queue); + $this->assertSame(1, $queue->getConnection()->size()); + } +} diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index 8322ffa80c..078c4e0c19 100644 --- a/tests/Integration/Queue/Redis/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -487,7 +487,7 @@ public function testPushJobQueueingAndJobQueuedEvents(): void return true; })->andReturnNull()->once(); - $container = m::mock(Container::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('bound')->with('events')->andReturn(true)->times(3); $container->shouldReceive('make')->with('events')->andReturn($events)->times(3); @@ -507,7 +507,7 @@ public function testBulkJobQueuedEvent(): void $events->shouldReceive('dispatch')->with(m::type(JobQueueing::class))->andReturnNull()->times(3); $events->shouldReceive('dispatch')->with(m::type(JobQueued::class))->andReturnNull()->times(3); - $container = m::mock(Container::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('has')->with('db.transactions')->andReturnFalse()->once(); $container->shouldReceive('bound')->with('events')->andReturn(true)->times(9); $container->shouldReceive('make')->with('events')->andReturn($events)->times(9); @@ -878,6 +878,60 @@ public function testTotalSizesPreserveQueueNamesAcrossEveryState(): void $this->assertSame(2, $this->queue->totalReservedSize()); } + public function testGlobalInspectionAndTotalsDoNotForwardPhysicalQueueNames(): void + { + $this->setQueue(); + + foreach (['reports' => 1, 'archive' => 2] as $name => $count) { + for ($index = 0; $index < $count; ++$index) { + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob($index)); + $this->queue->pop($name); + } + + for ($index = 0; $index < $count; ++$index) { + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob($index)); + $this->queue->laterOn($name, 60, new RedisQueueIntegrationTestJob($index)); + } + } + + $this->app->make('queue.routes')->forward('reports', 'archive'); + + $this->assertSame(9, $this->queue->totalSize()); + $this->assertSame(3, $this->queue->totalPendingSize()); + $this->assertSame(3, $this->queue->totalDelayedSize()); + $this->assertSame(3, $this->queue->totalReservedSize()); + + foreach (['pendingJobs', 'delayedJobs', 'reservedJobs'] as $method) { + $jobs = $this->queue->{'all' . ucfirst($method)}(); + + $this->assertSame(['archive', 'archive', 'reports'], $jobs->pluck('queue')->sort()->values()->all()); + $this->assertCount(3, $jobs->unique('uuid')); + $this->assertSame(['reports', 'reports'], $this->queue->{$method}('reports')->pluck('queue')->all()); + } + } + + public function testForwardedJobIsReleasedToTheSameDestination(): void + { + $this->setQueue('reports'); + $destinationKey = $this->getQueueRedisKey('processing'); + $otherKey = $this->getQueueRedisKey('archive'); + $this->app->make('queue.routes')->forward(['reports' => 'processing', 'processing' => 'archive']); + + $this->queue->push(new RedisQueueIntegrationTestJob(10)); + $job = $this->queue->pop(); + + $this->assertInstanceOf(RedisJob::class, $job); + $this->assertSame('reports', $job->getQueue()); + $job->release(0); + + $this->assertSame(1, $this->redisConnection()->zcard($destinationKey . ':delayed')); + $this->assertSame(0, $this->redisConnection()->zcard($otherKey . ':delayed')); + $retried = $this->queue->pop(); + $this->assertInstanceOf(RedisJob::class, $retried); + $this->assertSame($job->getJobId(), $retried->getJobId()); + $this->assertSame(2, $retried->attempts()); + } + public function testInvalidInspectedPayloadRetainsItsRedisRemovalMember(): void { $this->setQueue('poison'); diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 7676d99be2..924361fb52 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -6,6 +6,8 @@ use Closure; use Hypervel\Bus\Queueable; +use Hypervel\Config\Repository as Config; +use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\ShouldQueue; @@ -22,8 +24,10 @@ use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue; +use Hypervel\Queue\QueueRoutes; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; use Symfony\Component\Mailer\Exception\HttpTransportException; @@ -398,6 +402,42 @@ public function testItCanSendQueuedNotificationsWithQueueRoute(): void $sender->send($notifiable, new DummyQueuedNotificationWithStringVia); } + #[TestWith([null, 'cloud'])] + #[TestWith(['explicit', 'explicit'])] + public function testForwardedConnectionsUseTheSelectedChannelQueue(?string $connection, string $expectedConnection): void + { + $container = Container::getInstance(); + $container->instance('config', new Config); + $routes = new QueueRoutes; + $routes->forward('dummy', 'unused', 'wrong-connection'); + $routes->forward('admin_notifications', 'notifications', 'cloud'); + $container->instance('queue.routes', $routes); + $notification = new class extends DummyNotificationWithViaQueues { + /** + * Select an explicit connection for the database channel. + */ + public function viaConnections(): array + { + return ['database' => 'database-connection']; + } + }; + $notification->onConnection($connection); + $bus = m::mock(BusDispatcherContract::class); + $bus->shouldReceive('dispatch')->once()->with(m::on( + fn (SendQueuedNotifications $job): bool => $job->channels === ['mail'] + && $job->queue === 'admin_notifications' + && $job->connection === $expectedConnection + )); + $bus->shouldReceive('dispatch')->once()->with(m::on( + fn (SendQueuedNotifications $job): bool => $job->channels === ['database'] + && $job->queue === 'dummy' + && $job->connection === 'database-connection' + )); + + (new NotificationSender(new ChannelManager($container), $bus, m::mock(Dispatcher::class))) + ->send(new AnonymousNotifiable, $notification); + } + public function testItCanSendQueuedNotificationsWithDelayAttribute(): void { $notification = new #[Delay(30)] class extends Notification implements ShouldQueue { diff --git a/tests/Queue/FailoverQueueTest.php b/tests/Queue/FailoverQueueTest.php index 4bfc87fdb7..84af1c6b24 100644 --- a/tests/Queue/FailoverQueueTest.php +++ b/tests/Queue/FailoverQueueTest.php @@ -28,6 +28,7 @@ use Hypervel\Queue\Events\QueueFailedOver; use Hypervel\Queue\FailoverQueue; use Hypervel\Queue\QueueManager; +use Hypervel\Queue\QueueRoutes; use Hypervel\Queue\RedisQueue; use Hypervel\Queue\SyncQueue; use Hypervel\Support\Collection; @@ -44,6 +45,44 @@ class FailoverQueueTest extends TestCase { + #[DataProvider('forwardedQueueOperations')] + public function testConnectionScopedForwardsApplyBeforeDelegating(string $method, array $arguments, array $expectedArguments, Collection|int|string|null $result): void + { + $routes = new QueueRoutes; + $routes->forward(['default' => 'processing', 'reports' => 'processing', 'processing' => 'archive'], connection: 'failover'); + Container::getInstance()->instance('queue.routes', $routes); + $manager = m::mock(QueueManager::class); + $redis = m::mock(RedisQueue::class); + $manager->shouldReceive('connection')->once()->with('redis')->andReturn($redis); + $redis->shouldReceive($method)->once()->with(...$expectedArguments)->andReturn($result); + $queue = new FailoverQueue($manager, m::mock(DispatcherContract::class), ['redis']); + $queue->setConnectionName('failover'); + + $this->assertSame($result, $queue->{$method}(...$arguments)); + } + + /** + * Provide each queue-name boundary owned by the failover driver. + */ + public static function forwardedQueueOperations(): array + { + return [ + 'push' => ['push', ['job', '', 'reports'], ['job', '', 'processing'], 'id'], + 'push without queue' => ['push', ['job'], ['job'], 'id'], + 'pushRaw' => ['pushRaw', ['payload', 'reports'], ['payload', 'processing'], 'id'], + 'later' => ['later', [10, 'job', '', 'reports'], [10, 'job', '', 'processing'], 'id'], + 'pop' => ['pop', ['reports', 2], ['processing', 2], null], + 'size' => ['size', ['reports'], ['processing'], 7], + 'pendingSize' => ['pendingSize', ['reports'], ['processing'], 7], + 'delayedSize' => ['delayedSize', ['reports'], ['processing'], 7], + 'reservedSize' => ['reservedSize', ['reports'], ['processing'], 7], + 'pendingJobs' => ['pendingJobs', ['reports'], ['processing'], new Collection], + 'delayedJobs' => ['delayedJobs', ['reports'], ['processing'], new Collection], + 'reservedJobs' => ['reservedJobs', ['reports'], ['processing'], new Collection], + 'oldest pending' => ['creationTimeOfOldestPendingJob', ['reports'], ['processing'], 7], + ]; + } + public function testPushFailsOverOnException() { $failover = new FailoverQueue($queue = m::mock(QueueManager::class), $events = m::mock(DispatcherContract::class), [ diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index 122b4b9406..4122cc4a00 100644 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Queue; -use Hypervel\Container\Container as Application; -use Hypervel\Contracts\Container\Container; +use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Events\Dispatcher; use Hypervel\Queue\BeanstalkdQueue; use Hypervel\Queue\Events\JobQueued; @@ -33,7 +33,7 @@ class QueueBeanstalkdQueueTest extends TestCase private $queue; /** - * @var Container + * @var ContainerContract */ private $container; @@ -190,7 +190,7 @@ public function testJobQueuedReceivesTheExactBeanstalkdJobIdentifier(): void $queuedEvent = $event; }); - $container = new Application; + $container = new Container; $container->instance('events', $events); $this->queue->setContainer($container); @@ -269,6 +269,9 @@ public function testDeleteProperlyRemoveJobsOffBeanstalkd() $this->queue->deleteMessage('default', 1); } + /** + * Configure the queue and its container. + */ private function setQueue(string $default, int $timeToRun, int $blockFor = 0): void { $this->queue = new BeanstalkdQueue( @@ -278,7 +281,7 @@ private function setQueue(string $default, int $timeToRun, int $blockFor = 0): v $blockFor ); $this->queue->setConnectionName('beanstalkd'); - $this->container = m::spy(Container::class); + $this->container = m::spy(Container::class)->makePartial(); $this->queue->setContainer($this->container); } } diff --git a/tests/Queue/QueueCapsuleManagerTest.php b/tests/Queue/QueueCapsuleManagerTest.php new file mode 100644 index 0000000000..3fb59347bf --- /dev/null +++ b/tests/Queue/QueueCapsuleManagerTest.php @@ -0,0 +1,59 @@ +addConnection(['driver' => 'sync']); + $capsule->addConnection(['driver' => 'null'], 'discard'); + $job = new QueueCapsuleTestJob; + $capsule->getContainer()->instance(QueueCapsuleTestJob::class, $job); + + $capsule->getConnection()->push(QueueCapsuleTestJob::class, 'payload', 'emails'); + + $this->assertSame(['payload', 'default', 'emails'], $job->received); + $this->assertInstanceOf(NullQueue::class, $capsule->getConnection('discard')); + } + + public function testFailoverUsesTheCapsulesOwnManager(): void + { + $container = new Container; + $container->instance(DispatcherContract::class, new Dispatcher($container)); + $container->instance('queue', new QueueManager(new Container)); + $capsule = new Manager($container); + $capsule->addConnection(['driver' => 'failover', 'connections' => ['discard']]); + $capsule->addConnection(['driver' => 'null'], 'discard'); + + $queue = $capsule->getConnection(); + + $this->assertSame($capsule->getQueueManager(), $queue->manager); + $this->assertSame(0, $queue->size()); + } +} + +class QueueCapsuleTestJob +{ + public array $received = []; + + /** + * Record the delivered payload and queue identifiers. + */ + public function fire(Job $job, mixed $data): void + { + $this->received = [$data, $job->getConnectionName(), $job->getQueue()]; + } +} diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 85cd257779..96cf86c9cf 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -11,17 +11,20 @@ use Hypervel\Bus\DispatchLockContext; use Hypervel\Container\Container; use Hypervel\Contracts\Cache\Repository as CacheRepository; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder; +use Hypervel\Database\QueryException; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Events\Dispatcher; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\DatabaseQueue; +use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobPayloadFinalizing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; @@ -34,11 +37,13 @@ use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; +use PDOException; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Throwable; use TypeError; class QueueDatabaseQueueUnitTest extends TestCase @@ -105,6 +110,66 @@ public function testLockForPoppingUsesAConservativeFallbackForNonPdoConnections( $this->assertTrue($queue->lockForPopping()); } + #[DataProvider('transientReservationFailureProvider')] + public function testTransientReservationFailuresDoNotFailTheJob(Throwable $failure): void + { + [$queue, $events] = $this->createFailingReservationQueue($failure); + $queue->shouldReceive('deleteReserved')->never(); + $events->shouldReceive('dispatch')->never(); + + try { + $queue->pop(); + $this->fail('Expected the reservation failure.'); + } catch (Throwable $exception) { + $this->assertSame($failure, $exception); + } + } + + /** + * Provide reservation failures that do not indicate an invalid job. + */ + public static function transientReservationFailureProvider(): array + { + return [ + 'concurrency' => [new QueryException('database', 'update jobs', [], new PDOException('deadlock detected', 40001))], + 'lost connection' => [new QueryException('database', 'update jobs', [], new PDOException('server has gone away'))], + 'cancellation' => [new CanceledException('Reservation canceled.')], + ]; + } + + #[DataProvider('reservationCleanupFailureProvider')] + public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(Throwable $cleanupFailure): 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) { + $events->shouldReceive('dispatch')->never(); + } else { + $events->shouldReceive('hasListeners')->once()->with(JobFailed::class)->andReturn(false); + } + + try { + $queue->pop(); + $this->fail('Expected the reservation or cancellation failure.'); + } catch (Throwable $exception) { + $this->assertSame($cleanupFailure instanceof CanceledException ? $cleanupFailure : $failure, $exception); + } + } + + /** + * Provide failures while deleting a job that could not be reserved. + */ + public static function reservationCleanupFailureProvider(): array + { + return [ + 'ordinary failure' => [new RuntimeException('Deletion failed.')], + 'cancellation' => [new CanceledException('Deletion canceled.')], + ]; + } + #[DataProvider('pushJobsDataProvider')] public function testPushProperlyPushesJobOntoDatabase($uuid, $job, $displayNameStartsWith, $jobStartsWith) { @@ -117,7 +182,7 @@ public function testPushProperlyPushesJobOntoDatabase($uuid, $job, $displayNameS default: 'default', currentTime: 1732502704, ); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $resolver->shouldReceive('connection')->andReturn($connection = m::mock(ConnectionInterface::class)); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) use ($uuid, $displayNameStartsWith, $jobStartsWith) { @@ -167,7 +232,7 @@ public function testDelayedPushNeverRunsBeforeRequestedDeadline(DateInterval|Dat default: 'default', currentTime: 1000, ); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $connection = m::mock(ConnectionInterface::class); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->andReturn($connection); @@ -211,7 +276,7 @@ public function testPushIncludesBatchIdInPayloadForBatchableJob() default: 'default', currentTime: 1732502704, ); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $resolver->shouldReceive('connection')->andReturn($connection = m::mock(ConnectionInterface::class)); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) { @@ -955,6 +1020,36 @@ public function testInvalidInspectedPayloadIdentifiesItsQueueAndRecord(): void } } + /** + * Create a queue whose selected job cannot be reserved. + */ + private function createFailingReservationQueue(Throwable $failure): array + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + $connection->shouldReceive('transactionLevel')->andReturn(0); + $connection->shouldReceive('transaction')->once()->andReturnUsing(static fn (Closure $callback) => $callback()); + + $container = new Container; + $events = m::mock(DispatcherContract::class); + $container->instance(DispatcherContract::class, $events); + $queue = m::mock(DatabaseQueue::class, [$resolver, null, 'jobs']) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $queue->setContainer($container); + $queue->setConnectionName('database'); + $record = new DatabaseJobRecord((object) [ + 'id' => 1, + 'payload' => json_encode(['job' => stdClass::class, 'data' => []]), + 'attempts' => 255, + ]); + $queue->shouldReceive('getNextAvailableJob')->once()->with('default')->andReturn($record); + $queue->shouldReceive('marshalJob')->once()->with('default', $record)->andThrow($failure); + + return [$queue, $events]; + } + private function createInspectionQueue(): array { $resolver = m::mock(ConnectionResolverInterface::class); diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index 5df64a76b3..5e1ddafd1c 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -21,6 +21,7 @@ use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Queue\LuaScripts; use Hypervel\Queue\Queue; +use Hypervel\Queue\QueueRoutes; use Hypervel\Queue\RedisQueue; use Hypervel\Redis\RedisProxy; use Hypervel\Support\CarbonImmutable; @@ -35,8 +36,11 @@ class QueueRedisQueueTest extends TestCase { #[DataProvider('totalSizeMethods')] - public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $totalMethod, string $sizeMethod): void + public function testTotalsCountPhysicalQueuesInsidePinnedConnection(string $totalMethod, string $command, array $firstArguments, array $secondArguments): void { + $routes = new QueueRoutes; + $routes->forward('emails', 'reports:high'); + Container::getInstance()->instance('queue.routes', $routes); $pinned = false; $connection = m::mock(RedisProxy::class); $connection->expects('withPinnedConnection')->andReturnUsing(function (callable $callback) use (&$pinned): int { @@ -48,8 +52,19 @@ public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $to $pinned = false; } }); + $connection->shouldReceive('isCluster')->once()->andReturnFalse(); + $connection->shouldReceive($command)->once()->with(...$firstArguments)->andReturnUsing(function () use (&$pinned): int { + $this->assertTrue($pinned); + + return 5; + }); + $connection->shouldReceive($command)->once()->with(...$secondArguments)->andReturnUsing(function () use (&$pinned): int { + $this->assertTrue($pinned); + + return 7; + }); $redis = m::mock(Redis::class); - $redis->expects('connection')->with(null)->andReturn($connection); + $redis->shouldReceive('connection')->with(null)->andReturn($connection); $queue = m::mock(RedisQueue::class, [$redis, 'default']) ->makePartial() ->shouldAllowMockingProtectedMethods(); @@ -58,28 +73,23 @@ public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $to return new Collection(['emails', 'reports:high']); }); - $queue->shouldReceive($sizeMethod)->twice()->andReturnUsing(function (string $name) use (&$pinned): int { - $this->assertTrue($pinned); - - return match ($name) { - 'emails' => 5, - 'reports:high' => 7, - }; - }); - $this->assertSame(12, $queue->{$totalMethod}()); } /** - * Provide aggregate methods and their per-queue extension points. + * Provide aggregate methods and their physical Redis commands. */ public static function totalSizeMethods(): array { return [ - 'all jobs' => ['totalSize', 'size'], - 'pending jobs' => ['totalPendingSize', 'pendingSize'], - 'delayed jobs' => ['totalDelayedSize', 'delayedSize'], - 'reserved jobs' => ['totalReservedSize', 'reservedSize'], + 'all jobs' => [ + 'totalSize', 'eval', + [LuaScripts::size(), 3, 'queues:emails', 'queues:emails:delayed', 'queues:emails:reserved'], + [LuaScripts::size(), 3, 'queues:reports:high', 'queues:reports:high:delayed', 'queues:reports:high:reserved'], + ], + 'pending jobs' => ['totalPendingSize', 'llen', ['queues:emails'], ['queues:reports:high']], + 'delayed jobs' => ['totalDelayedSize', 'zcard', ['queues:emails:delayed'], ['queues:reports:high:delayed']], + 'reserved jobs' => ['totalReservedSize', 'zcard', ['queues:emails:reserved'], ['queues:reports:high:reserved']], ]; } @@ -281,7 +291,7 @@ public function testPushProperlyPushesJobOntoRedis(): void $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldAllowMockingMethod('evalWithShaCache'); @@ -302,7 +312,7 @@ public function testPushProperlyPushesJobOntoRedisWithCustomPayloadHook(): void $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldAllowMockingMethod('evalWithShaCache'); @@ -329,7 +339,7 @@ public function testJobQueueingAndQueuedEventsAreSkippedWhenNoListenersAreRegist $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::mock(Container::class)); + $queue->setContainer($container = m::mock(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -447,7 +457,7 @@ public function testPushRaisesFailedEventWhenRedisThrows(): void $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::mock(Container::class)); + $queue->setContainer($container = m::mock(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -500,7 +510,7 @@ public function testPushProperlyPushesJobOntoRedisWithTwoCustomPayloadHook(): vo $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldAllowMockingMethod('evalWithShaCache'); @@ -530,7 +540,7 @@ public function testDelayedPushProperlyPushesJobOntoRedis(): void $uuid = $this->mockUuid(); $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); @@ -557,7 +567,7 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoRedis(): void $date = CarbonImmutable::createFromTimestampUTC('1001.100000'); $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); @@ -583,7 +593,7 @@ public function testDelayedPushWithIntervalNeverRunsBeforeRequestedLifetime(): v $delay = new DateInterval('PT1S'); $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); @@ -686,7 +696,7 @@ public function testPushUsesClusterSafeRedisKeyForLuaScript(): void ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) ->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -714,7 +724,7 @@ public function testPushPassesLogicalQueueToPayloadCallbacksOnCluster(): void ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) ->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer(m::spy(Container::class)); + $queue->setContainer(m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -750,7 +760,7 @@ public function testLaterUsesClusterSafeRedisKeyForDelayedSet(): void ->onlyMethods(['availableAt', 'getRandomId']) ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) ->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); $queue->expects($this->once())->method('availableAt')->with(1)->willReturn(2); diff --git a/tests/Queue/QueueRouteContainerTest.php b/tests/Queue/QueueRouteContainerTest.php new file mode 100644 index 0000000000..c8aca43c5c --- /dev/null +++ b/tests/Queue/QueueRouteContainerTest.php @@ -0,0 +1,90 @@ +instance('queue.routes', $foreign = new QueueRoutes); + $foreign->set(stdClass::class, 'foreign-queue', 'foreign-connection'); + $owner = new Container; + $owner->instance('config', new Repository); + $owner->instance('queue.routes', $routes = new QueueRoutes); + $routes->set(stdClass::class, 'owner-queue', 'owner-connection'); + $consumer = $class === NullQueue::class + ? (new NullQueue)->setContainer($owner) + : new $class($owner); + $job = new stdClass; + + $this->assertSame('owner-queue', $consumer->resolveQueueFromQueueRoute($job)); + $this->assertSame('owner-connection', $consumer->resolveConnectionFromQueueRoute($job)); + + $owner->instance('queue.routes', $replacement = new QueueRoutes); + $replacement->set(stdClass::class, 'replacement-queue', 'replacement-connection'); + + $this->assertSame('replacement-queue', $consumer->resolveQueueFromQueueRoute($job)); + $this->assertSame('replacement-connection', $consumer->resolveConnectionFromQueueRoute($job)); + } + + /** + * Provide the framework services that resolve queue routes. + */ + public static function routingConsumers(): array + { + return [ + 'queue manager' => [QueueManager::class], + 'queue' => [NullQueue::class], + 'bus' => [BusDispatcher::class], + 'events' => [Dispatcher::class], + 'broadcasts' => [BroadcastManager::class], + 'notifications' => [ChannelManager::class], + ]; + } + + public function testRoutesPersistWithoutABindingAndRemainLocalToTheirContainer(): void + { + $owner = new Container; + $manager = new QueueManager($owner); + $manager->route(stdClass::class, 'reports'); + $manager->forward('reports', 'processing', 'redis'); + $secondManager = new QueueManager($owner); + $otherManager = new QueueManager(new Container); + $job = new stdClass; + + $this->assertSame('reports', $secondManager->resolveQueueFromQueueRoute($job)); + $this->assertSame('redis', $secondManager->resolveConnectionFromQueueRoute($job)); + $this->assertNull($otherManager->resolveQueueFromQueueRoute($job)); + $this->assertNull($otherManager->resolveConnectionFromQueueRoute($job)); + } + + public function testProviderPreservesRoutesRegisteredBeforeItsBinding(): void + { + $application = new Application; + $manager = new QueueManager($application); + $manager->route(stdClass::class, 'reports'); + + (new QueueServiceProvider($application))->register(); + + $this->assertSame('reports', $manager->resolveQueueFromQueueRoute(new stdClass)); + } +} diff --git a/tests/Queue/QueueRoutesTest.php b/tests/Queue/QueueRoutesTest.php index 4d9d3da4f9..62bf35f65d 100644 --- a/tests/Queue/QueueRoutesTest.php +++ b/tests/Queue/QueueRoutesTest.php @@ -5,8 +5,10 @@ namespace Hypervel\Tests\Queue\QueueRoutesTest; use Hypervel\Foundation\Queue\Queueable; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\QueueRoutes; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; class QueueRoutesTest extends TestCase { @@ -73,6 +75,151 @@ public function testGetConnection(): void $this->assertNull($defaults->getConnection(new Payment)); } + public function testStringRouteDefaultsToQueueNotConnection(): void + { + $defaults = new QueueRoutes; + + $defaults->set([BaseNotification::class => 'notifications']); + + $this->assertSame('notifications', $defaults->getQueue(new FinanceNotification)); + $this->assertNull($defaults->getConnection(new FinanceNotification)); + } + + public function testForwardRewritesName(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit'); + + $this->assertSame('audit', $defaults->forwardedQueue('reports')); + $this->assertSame('audit', $defaults->forwardedQueue('reports', 'cloud')); + $this->assertSame('other', $defaults->forwardedQueue('other')); + } + + public function testForwardIsScopedToConnection(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit', 'cloud'); + + $this->assertSame('audit', $defaults->forwardedQueue('reports', 'cloud')); + $this->assertSame('reports', $defaults->forwardedQueue('reports', 'redis')); + $this->assertSame('reports', $defaults->forwardedQueue('reports')); + } + + public function testForwardWithJustConnectionKeepsName(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', connection: 'cloud'); + + $this->assertSame('reports', $defaults->forwardedQueue('reports', 'cloud')); + } + + public function testForwardSetsConnectionByQueueName(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit', 'cloud'); + + $this->assertSame('cloud', $defaults->getConnection((new SomeJob)->onQueue('reports'))); + $this->assertNull($defaults->getConnection((new SomeJob)->onQueue('other'))); + $this->assertNull($defaults->getConnection(new SomeJob)); + } + + public function testForwardMatchesQueueAttribute(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit', 'cloud'); + + $this->assertSame('cloud', $defaults->getConnection(new AttributeForwardedJob)); + } + + public function testForwardAcceptsArray(): void + { + $defaults = new QueueRoutes; + + $defaults->forward([ + 'reports' => 'audit', + 'emails' => 'mail', + ], connection: 'cloud'); + + $this->assertSame('audit', $defaults->forwardedQueue('reports', 'cloud')); + $this->assertSame('mail', $defaults->forwardedQueue('emails', 'cloud')); + $this->assertSame('reports', $defaults->forwardedQueue('reports', 'redis')); + } + + public function testForwardResolvesEnums(): void + { + $defaults = new QueueRoutes; + + $defaults->forward(QueueName::Payments, 'settlements', ConnectionName::Redis); + + $this->assertSame('settlements', $defaults->forwardedQueue('payments', 'redis')); + $this->assertSame('payments', $defaults->forwardedQueue('payments', 'sqs')); + } + + #[TestWith(['reports'])] + #[TestWith([[null, 'reports']])] + public function testForwardedConnectionUsesClassRouteWithoutConnection(array|string $route): void + { + $defaults = new QueueRoutes; + $defaults->set([SomeJob::class => $route]); + $defaults->forward('reports', 'audit', 'cloud'); + $defaults->forward('updates', 'notifications', 'redis'); + + $this->assertSame('cloud', $defaults->getConnection(new SomeJob)); + $this->assertSame('redis', $defaults->getConnection((new SomeJob)->onQueue('updates'))); + $this->assertSame('redis', $defaults->getConnection((new SomeJob)->onQueue('reports'), 'updates')); + + $defaults->set(SomeJob::class, 'reports', 'explicit'); + + $this->assertSame('explicit', $defaults->getConnection(new SomeJob, 'updates')); + } + + public function testForwardNormalizesIntegerAndUnitEnumsWithoutLosingZero(): void + { + $defaults = new QueueRoutes; + $defaults->forward(QueueRouteIntegerIdentifier::Queue, QueueRouteIntegerIdentifier::Zero, QueueRouteIntegerIdentifier::Zero); + + $this->assertSame('0', $defaults->forwardedQueue('1', '0')); + $this->assertSame('0', $defaults->getConnection((new SomeJob)->onQueue(QueueRouteIntegerIdentifier::Queue))); + + $defaults->forward(['0' => QueueName::Payments], connection: QueueRouteUnitIdentifier::Connection); + + $this->assertSame('payments', $defaults->forwardedQueue('0', 'Connection')); + $this->assertSame('Connection', $defaults->getConnection((new SomeJob)->onQueue(QueueRouteIntegerIdentifier::Zero))); + } + + public function testConnectionScopedForwardingLeavesUnscopedForwardsToTheStorageDriver(): void + { + $defaults = new QueueRoutes; + $defaults->forward('reports', 'processing', 'failover'); + $defaults->forward('processing', 'archive'); + + $this->assertSame('processing', $defaults->forwardedQueueForConnection('reports', 'failover')); + $this->assertSame('reports', $defaults->forwardedQueueForConnection('reports', 'redis')); + $this->assertSame('reports', $defaults->forwardedQueueForConnection('reports', null)); + $this->assertSame('processing', $defaults->forwardedQueueForConnection('processing', 'failover')); + $this->assertSame('archive', $defaults->forwardedQueue('processing', 'redis')); + } + + public function testEnumsAreResolved(): void + { + $defaults = new QueueRoutes; + + $defaults->set(SomeJob::class, QueueName::Payments, ConnectionName::Redis); + + $this->assertSame('payments', $defaults->getQueue(new SomeJob)); + $this->assertSame('redis', $defaults->getConnection(new SomeJob)); + + $defaults->set([SomeJob::class => [ConnectionName::Redis, QueueName::Payments]]); + + $this->assertSame('payments', $defaults->getQueue(new SomeJob)); + $this->assertSame('redis', $defaults->getConnection(new SomeJob)); + } + public function testEnumRoutesAreNormalizedAndScalarRoutesRemainQueueOnly(): void { $defaults = new QueueRoutes; @@ -92,6 +239,16 @@ public function testEnumRoutesAreNormalizedAndScalarRoutesRemainQueueOnly(): voi } } +enum QueueName: string +{ + case Payments = 'payments'; +} + +enum ConnectionName: string +{ + case Redis = 'redis'; +} + trait CustomTrait { } @@ -102,6 +259,12 @@ class SomeJob use CustomTrait; } +#[QueueAttribute('reports')] +class AttributeForwardedJob +{ + use Queueable; +} + class BaseNotification { use Queueable; diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index 7303dd6c8f..6f85e4eaa0 100644 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -142,18 +142,12 @@ protected function createMockedUuid(string $value): Uuid return Uuid::fromString($value); } + /** + * Create a container spy with real service resolution. + */ protected function createSpyContainer(): Container { - $container = m::spy(Container::class); - - $container->shouldReceive('has') - ->with('queue.routes') - ->andReturn(true); - $container->shouldReceive('make') - ->with('queue.routes') - ->andReturn(new QueueRoutes); - - return $container; + return m::spy(Container::class)->makePartial(); } public function testPopProperlyPopsJobOffOfSqs() @@ -181,7 +175,7 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoSqs(): void { $now = CarbonImmutable::now(); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('secondsUntil')->with($now->addSeconds(5))->willReturn(5); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); @@ -194,7 +188,7 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoSqs(): void public function testDelayedPushProperlyPushesJobOntoSqs() { $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('secondsUntil')->with($this->mockedDelay)->willReturn($this->mockedDelay); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); @@ -207,7 +201,7 @@ public function testDelayedPushProperlyPushesJobOntoSqs() public function testPushProperlyPushesJobOntoSqs() { $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload])->andReturn($this->mockedSendMessageResponseModel); @@ -224,7 +218,7 @@ public function testPushPreservesZeroQueueAndDefaultsEmptyQueue(string $requeste ->onlyMethods(['createPayload', 'getQueue']) ->setConstructorArgs([$this->sqs, $this->queueName, $this->prefix]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $logicalQueue, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($requestedQueue)->willReturn($queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -246,7 +240,7 @@ public function testLaterPreservesZeroQueueAndDefaultsEmptyQueue(string $request ->onlyMethods(['createPayload', 'getQueue', 'secondsUntil']) ->setConstructorArgs([$this->sqs, $this->queueName, $this->prefix]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $logicalQueue, $this->mockedData, $this->mockedDelay)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($requestedQueue)->willReturn($queueUrl); $queue->expects($this->once())->method('secondsUntil')->with($this->mockedDelay)->willReturn($this->mockedDelay); @@ -437,6 +431,43 @@ public function testGetQueueProperlyResolvesFifoUrlWithSuffix() $this->assertEquals($queueUrl, $queue->getQueue('test.fifo')); } + public function testForwardedQueueNameIsUsedWhenPushing(): void + { + Container::setInstance($container = new Container); + $routes = new QueueRoutes; + $routes->forward('jobs', 'processing', 'sqs'); + $container->instance('queue.routes', $routes); + + $queue = new SqsQueue($this->sqs, 'default', $this->prefix); + $queue->setConnectionName('sqs'); + + $this->sqs->expects('sendMessage')->with([ + 'QueueUrl' => $this->prefix . 'processing', + 'MessageBody' => 'payload', + ])->andReturn($this->mockedSendMessageResponseModel); + + $queue->pushRaw('payload', 'jobs'); + } + + public function testForwardedFifoQueueControlsOptionsAndDelayValidation(): void + { + $routes = new QueueRoutes; + $routes->forward(['jobs' => 'processing.fifo', 'processing.fifo' => 'archive'], connection: 'sqs'); + Container::getInstance()->instance('queue.routes', $routes); + $queue = new SqsQueue($this->sqs, 'default', $this->prefix); + $queue->setConnectionName('sqs'); + + $this->assertSame($this->prefix . 'processing.fifo', $queue->getQueue('jobs')); + $options = $queue->getQueueableOptions('job', 'jobs', 'payload'); + $this->assertSame('processing.fifo', $options['MessageGroupId']); + $this->assertArrayHasKey('MessageDeduplicationId', $options); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('SQS FIFO queues do not support per-message delays.'); + + $queue->later(10, 'job', '', 'jobs'); + } + public function testGetQueueEnsuresTheQueueIsOnlySuffixedOnce() { $queue = new SqsQueue($this->sqs, "{$this->queueName}-staging", $this->prefix, $suffix = '-staging'); @@ -458,7 +489,7 @@ public function testPushProperlyPushesJobObjectOntoSqs() $job = new FakeSqsJob; $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload])->andReturn($this->mockedSendMessageResponseModel); @@ -495,7 +526,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFairQueue() $job = (new FakeSqsJob)->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload, 'MessageGroupId' => $this->mockedMessageGroupId])->andReturn($this->mockedSendMessageResponseModel); @@ -531,7 +562,7 @@ public function testPushProperlyPushesJobStringOntoSqsFifoQueue() Str::createUuidsUsing(fn () => $this->createMockedUuid($this->mockedDeduplicationId)); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -554,7 +585,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueue() $job = (new FakeSqsJob)->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -578,7 +609,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithMessageGroupM $job->expects($this->once())->method('messageGroup')->willReturn($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -605,7 +636,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithMessageGroupP $job->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -628,7 +659,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithDeduplication $job->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -656,7 +687,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithDeduplicator( }); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -817,7 +848,7 @@ public function testDelayedPushRejectsPositiveDelayForStringJobOnSqsFifoQueue(): ->onlyMethods(['createPayload']) ->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->never())->method('createPayload'); $this->sqs->shouldNotReceive('sendMessage'); @@ -835,7 +866,7 @@ public function testDelayedPushRejectsPositiveDelayForObjectJobOnSqsFifoQueue(): ->onlyMethods(['createPayload']) ->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->never())->method('createPayload'); $this->sqs->shouldNotReceive('sendMessage'); @@ -877,7 +908,7 @@ public function testNonPositiveAndElapsedDelaysRemainImmediateOnSqsFifoQueue(int ->onlyMethods(['createPayload', 'getQueue']) ->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with( $this->mockedJob, $this->fifoQueueName, @@ -926,7 +957,7 @@ public function testPushRawStoresOverflowPayloadAndSendsItsPointer(): void $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -953,8 +984,8 @@ public function testPushRawDoesNotResolveOverflowStorageWhenDisabledOrBelowThres 'data' => 'small', ], JSON_THROW_ON_ERROR); - $container = m::mock(ContainerContract::class); - $container->shouldNotReceive('make'); + $container = m::mock(Container::class)->makePartial(); + $container->shouldNotReceive('make')->with('cache'); $queue = new SqsQueue( $this->sqs, @@ -987,7 +1018,7 @@ public function testPushRawAlwaysStoresOverflowPayloadWhenConfigured(): void $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -1025,7 +1056,7 @@ function (string $candidate, string $stored) use (&$path, $payload): bool { $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -1138,7 +1169,7 @@ public function testPushRawRetainsOverflowPayloadWhenSqsDeliveryIsAmbiguous(): v $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -1169,7 +1200,7 @@ public function testPushRawRetainsOverflowPayloadWhenSqsDeliveryIsCanceled(): vo $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php index bb9bfcb5bb..aae8298bb0 100644 --- a/tests/Support/SupportArrTest.php +++ b/tests/Support/SupportArrTest.php @@ -365,16 +365,16 @@ public function testExceptValues(): void $array = ['a' => 1, 'b' => 2, 'c' => 1, 'd' => 3]; $this->assertEquals(['b' => 2, 'd' => 3], Arr::exceptValues($array, 1)); - $this->assertEquals([], Arr::exceptValues([], 'foo')); + $this->assertSame([], Arr::exceptValues([], 'foo')); $this->assertEquals(['foo', 'bar'], Arr::exceptValues(['foo', 'bar'], [])); $array = [1, '1', 2, '2', 3]; $this->assertEquals([1 => '1', 3 => '2'], Arr::exceptValues($array, [1, 2, 3], true)); - $this->assertEquals([], Arr::exceptValues($array, [1, 2, 3])); + $this->assertSame([], Arr::exceptValues($array, [1, 2, 3])); $array = ['a' => true, 'b' => false, 'c' => 1, 'd' => 0]; $this->assertEquals(['a' => true, 'b' => false], Arr::exceptValues($array, [1, 0], true)); - $this->assertEquals([], Arr::exceptValues($array, [1, 0])); + $this->assertSame([], Arr::exceptValues($array, [1, 0])); } public function testExists(): void @@ -1076,8 +1076,8 @@ public function testOnlyValues(): void $array = ['a' => 1, 'b' => 2, 'c' => 1, 'd' => 3]; $this->assertEquals(['a' => 1, 'c' => 1], Arr::onlyValues($array, 1)); - $this->assertEquals([], Arr::onlyValues([], 'foo')); - $this->assertEquals([], Arr::onlyValues(['foo', 'bar'], [])); + $this->assertSame([], Arr::onlyValues([], 'foo')); + $this->assertSame([], Arr::onlyValues(['foo', 'bar'], [])); $array = [1, '1', 2, '2', 3]; $this->assertEquals([0 => 1, 2 => 2, 4 => 3], Arr::onlyValues($array, [1, 2, 3], true)); diff --git a/tests/Support/SupportBinaryCodecTest.php b/tests/Support/SupportBinaryCodecTest.php index bc9fe2525f..cb7902d643 100644 --- a/tests/Support/SupportBinaryCodecTest.php +++ b/tests/Support/SupportBinaryCodecTest.php @@ -63,19 +63,22 @@ public function testRegisterOverridesDefaultFormat(): void } #[DataProvider('nullAndBlankProvider')] - public function testEncodeReturnsNullForNullAndBlank(mixed $value): void + public function testEncodeReturnsNullForNullAndBlank(?string $value): void { $this->assertNull(BinaryCodec::encode($value, 'uuid')); $this->assertNull(BinaryCodec::encode($value, 'ulid')); } #[DataProvider('nullAndBlankProvider')] - public function testDecodeReturnsNullForNullAndBlank(mixed $value): void + public function testDecodeReturnsNullForNullAndBlank(?string $value): void { $this->assertNull(BinaryCodec::decode($value, 'uuid')); $this->assertNull(BinaryCodec::decode($value, 'ulid')); } + /** + * Provide null and blank values. + */ public static function nullAndBlankProvider(): array { return [ @@ -209,6 +212,9 @@ public function testBlankBuiltInBinaryValuesRoundTrip(string $format, string $bi $this->assertSame($binary, BinaryCodec::encode($text, $format)); } + /** + * Provide binary identifiers whose bytes are blank strings. + */ public static function blankBuiltInBinaryProvider(): array { return [ diff --git a/tests/Support/SupportCapsuleManagerTraitTest.php b/tests/Support/SupportCapsuleManagerTraitTest.php index f21e135a9b..6c6a784f7d 100644 --- a/tests/Support/SupportCapsuleManagerTraitTest.php +++ b/tests/Support/SupportCapsuleManagerTraitTest.php @@ -6,7 +6,6 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; -use Hypervel\Support\Fluent; use Hypervel\Support\Traits\CapsuleManagerTrait; use Hypervel\Tests\TestCase; use ReflectionClass; @@ -21,20 +20,23 @@ public function testSetupContainerForCapsule(): void $this->setupContainer($app); $this->assertSame($app, $this->getContainer()); - $this->assertInstanceOf(Fluent::class, $app->make('config')); + $config = $app->make('config'); + $this->assertInstanceOf(Repository::class, $config); + $config->set('queue.default', 'default'); + $this->assertSame('default', $config->string('queue.default')); } public function testSetupContainerForCapsuleWhenConfigIsBound(): void { $app = new Container; - $app->instance('config', new Repository([])); + $app->instance('config', $config = new Repository([])); $this->setupContainer($app); $this->assertSame($app, $this->getContainer()); - $this->assertInstanceOf(Repository::class, $app->make('config')); + $this->assertSame($config, $app->make('config')); } - public function testFlushStateClearsGlobalInstance() + public function testFlushStateClearsGlobalInstance(): void { $this->setAsGlobal(); $this->assertSame($this, $this->getStaticInstance()); @@ -44,6 +46,9 @@ public function testFlushStateClearsGlobalInstance() $this->assertNull($this->getStaticInstance()); } + /** + * Get the globally selected Capsule instance. + */ private function getStaticInstance(): ?object { return (new ReflectionClass(static::class))->getStaticPropertyValue('instance'); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index e710cb8c95..c87cb5859a 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -7782,7 +7782,7 @@ public function testValidateImplicitEachWithAsterisksForRequiredNonExistingKey() $this->assertFalse($v->passes()); } - public function testParsingArrayKeysWithDot() + public function testParsingArrayKeysWithDot(): void { $trans = $this->getArrayTranslator(); // Interpreted dot fails on empty value @@ -7791,7 +7791,7 @@ public function testParsingArrayKeysWithDot() // Escaped dot fails on empty value $v = new Validator($trans, ['foo' => ['bar' => 'valid'], 'foo.bar' => ''], ['foo\.bar' => 'required']); $this->assertTrue($v->fails()); - // Interpreted dot succeeds + // Escaped dot succeeds $v = new Validator($trans, ['foo' => ['bar' => 'valid'], 'foo.bar' => 'zxc'], ['foo\.bar' => 'required']); $this->assertFalse($v->fails()); // Interpreted dot followed by escaped dot fails on empty value @@ -7800,6 +7800,22 @@ public function testParsingArrayKeysWithDot() // Interpreted dot followed by escaped dot fails on empty value $v = new Validator($trans, ['foo' => [['bar.baz' => ''], ['bar.baz' => '']]], ['foo.*.bar\.baz' => 'required']); $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo.bar' => 'valid'], ['foo\.bar' => 'required']); + $this->assertFalse($v->fails()); + + $v = new Validator($trans, ['foo.bar' => 'valid'], []); + $v->appendRules(['foo\.bar' => 'required']); + $this->assertFalse($v->fails()); + + $v = new Validator($trans, ['foo.bar' => 'valid'], []); + $v->sometimes('foo\.bar', 'required', fn (): bool => true); + $this->assertFalse($v->fails()); + + $v = new Validator($trans, ['name' => 'ab'], ['name' => 'required']); + $v->appendRules(['name' => 'string']); + $v->appendRules(['name' => 'min:5|max:255']); + $this->assertTrue($v->fails()); } public function testParsingArrayKeysWithAsterisk(): void diff --git a/types/Database/Migrations.php b/types/Database/Migrations.php new file mode 100644 index 0000000000..fb3deef27e --- /dev/null +++ b/types/Database/Migrations.php @@ -0,0 +1,46 @@ +', $repository->getRan()); + assertType('array', $database->getRan()); + assertType('array', $repository->getMigrationBatches()); + assertType('array', $database->getMigrationBatches()); + + assertType('array', $repository->getMigrations(1)); + assertType('array', $database->getMigrations(1)); + assertType('array', $repository->getMigrationsByBatch(1)); + assertType('array', $database->getMigrationsByBatch(1)); + assertType('array', $repository->getLast()); + assertType('array', $database->getLast()); + + $repository->delete((object) ['migration' => 'create_users_table']); + $database->delete((object) ['migration' => 'create_users_table']); +} + +function testMigrationCallbackTypes(MigrationCreator $creator, Connection $connection): void +{ + $creator->afterCreate(function ($table, $path): void { + assertType('string|null', $table); + assertType('string', $path); + }); + + Migrator::resolveConnectionsUsing(function ($resolver, $name) use ($connection): Connection { + assertType('Hypervel\Database\ConnectionResolverInterface', $resolver); + assertType('string|null', $name); + + return $connection; + }); +} diff --git a/types/Database/Schema.php b/types/Database/Schema.php index 5f9c56bb7a..5655403ce9 100644 --- a/types/Database/Schema.php +++ b/types/Database/Schema.php @@ -5,6 +5,7 @@ namespace Hypervel\Types\Database\Schema; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\ColumnDefinition; use function PHPStan\Testing\assertType; @@ -52,6 +53,43 @@ function testIndexDefinitionsUseConcreteTypes(Blueprint $table): void ); } +function testSchemaCallbackAndReferenceTypes(Builder $schema): void +{ + assertType('int<0, max>|null', Builder::$defaultStringLength); + assertType("'int'|'ulid'|'uuid'", Builder::$defaultMorphKeyType); + assertType('Closure(int<0, max>): void', Builder::defaultStringLength(...)); + assertType('42', $schema->withoutForeignKeyConstraints(fn (): int => 42)); + assertType('array{string|null, string}', $schema->parseSchemaAndTable('users')); + + new Blueprint($schema->getConnection(), 'users', function ($table): void { + assertType('Hypervel\Database\Schema\Blueprint', $table); + + $table->after('id', function ($table): void { + assertType('Hypervel\Database\Schema\Blueprint', $table); + }); + }); +} + +function testDdlLockTypes(Blueprint $table): void +{ + assertType( + "Closure('default'|'exclusive'|'none'|'shared'): Hypervel\\Database\\Schema\\ColumnDefinition", + $table->string('name')->lock(...), + ); + assertType( + "Closure('default'|'exclusive'|'none'|'shared'): Hypervel\\Database\\Schema\\IndexDefinition", + $table->index('name')->lock(...), + ); + assertType( + "Closure('default'|'exclusive'|'none'|'shared'): Hypervel\\Database\\Schema\\ForeignKeyDefinition", + $table->foreign('user_id')->lock(...), + ); + assertType( + 'Closure(array|string): Hypervel\Database\Schema\ForeignKeyDefinition', + $table->foreign('user_id')->references(...), + ); +} + class CustomColumnDefinition extends ColumnDefinition { /**